Replaces the fixed 5-value Role enum with a database-driven Role/Permission model: admins can create custom roles with a precise per-resource/action permission set (83-key code-owned catalog), instead of being locked to ADMIN/BEEKEEPER/STAFF/READONLY/API_CLIENT. - New Role/Permission/RolePermission tables; legacy Role enum renamed to LegacyRole and kept as User.legacyRole so all existing @Roles() call sites keep working unchanged during the migration. - New RolesController/RolesService (CRUD, permission-set editing, deactivate/reactivate) with lockout-prevention invariants: system roles (ADMIN) can't be edited or deactivated, and a role still assigned to users can't be deactivated. - New PermissionResolverService (30s-cached, DB-fresh per user) and PermissionsGuard + @RequirePermission(), registered as an additional APP_GUARD alongside the legacy RolesGuard so both systems run side-by-side until controllers are migrated in Phase 2. - PermissionCatalogSyncService seeds any new catalog entries into the DB on boot, so future features only touch the TS catalog. - New GET /auth/me returns the caller's role and effective permissions. - Migration hand-written (destructive column rename) with grants derived from a fresh grep of every existing @Roles() call site, verified against 5 controllers read in full for STAFF's exact write boundary. - test/roles.e2e-spec.ts covers the new endpoints and invariants end to end; test/authorization-coverage.e2e-spec.ts added but skipped until Phase 2 gives every controller a @RequirePermission. Co-Authored-By: Claude Sonnet 5 <[email protected]>
109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Post,
|
|
Req,
|
|
Res,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import type { Request, Response } from 'express';
|
|
import { Public } from './decorators/public.decorator';
|
|
import { CurrentUser } from './decorators/current-user.decorator';
|
|
import type { AuthenticatedUser } from './types/authenticated-user';
|
|
import { LoginDto } from './dto/login.dto';
|
|
import { AccessTokenDto } from './dto/access-token.dto';
|
|
import { AuthService, CurrentUserProfile } from './auth.service';
|
|
import { parseDurationToMs } from './util/duration';
|
|
|
|
const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
|
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
|
// Stricter than the global default: bounds brute-force/credential-stuffing
|
|
// attempts against login and refresh-token guessing specifically.
|
|
const AUTH_THROTTLE = { default: { limit: 10, ttl: 60000 } };
|
|
|
|
@ApiTags('auth')
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
constructor(private readonly authService: AuthService) {}
|
|
|
|
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
|
|
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
|
httpOnly: true,
|
|
// Explicit opt-out for local HTTP dev only; every real deployment must
|
|
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
|
// over plaintext HTTP.
|
|
secure: process.env.COOKIE_SECURE !== 'false',
|
|
sameSite: 'lax',
|
|
path: REFRESH_TOKEN_COOKIE_PATH,
|
|
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
|
});
|
|
}
|
|
|
|
private clearRefreshTokenCookie(res: Response): void {
|
|
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
|
|
}
|
|
|
|
private extractRefreshToken(req: Request): string {
|
|
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
|
|
REFRESH_TOKEN_COOKIE
|
|
];
|
|
if (!refreshToken) {
|
|
throw new UnauthorizedException('Missing refresh token');
|
|
}
|
|
return refreshToken;
|
|
}
|
|
|
|
@Public()
|
|
@Throttle(AUTH_THROTTLE)
|
|
@Post('login')
|
|
@HttpCode(HttpStatus.OK)
|
|
async login(
|
|
@Body() dto: LoginDto,
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<AccessTokenDto> {
|
|
const { accessToken, refreshToken } = await this.authService.login(
|
|
dto.email,
|
|
dto.password,
|
|
);
|
|
this.setRefreshTokenCookie(res, refreshToken);
|
|
return { accessToken };
|
|
}
|
|
|
|
@Public()
|
|
@Throttle(AUTH_THROTTLE)
|
|
@Post('refresh')
|
|
@HttpCode(HttpStatus.OK)
|
|
async refresh(
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<AccessTokenDto> {
|
|
const { accessToken, refreshToken } = await this.authService.refresh(
|
|
this.extractRefreshToken(req),
|
|
);
|
|
this.setRefreshTokenCookie(res, refreshToken);
|
|
return { accessToken };
|
|
}
|
|
|
|
@Public()
|
|
@Post('logout')
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
async logout(
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<void> {
|
|
await this.authService.logout(this.extractRefreshToken(req));
|
|
this.clearRefreshTokenCookie(res);
|
|
}
|
|
|
|
@ApiBearerAuth()
|
|
@Get('me')
|
|
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
|
|
return this.authService.getCurrentUserProfile(user.id);
|
|
}
|
|
}
|