feat(security): HttpOnly cookie sessions, token revocation, and RBAC
- Add User.role (enum Role/ADMIN) and User.tokenVersion with migration - Login now issues short-lived access token (30m default) + 7d refresh token, both embedding tokenVersion and a typ discriminator - Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped to /auth); refresh token never leaves the cookie - New endpoints: POST /auth/refresh (rotation), GET /auth/me, POST /auth/logout (bumps tokenVersion, revoking all tokens) - JWT strategy accepts bearer or cookie, rejects refresh tokens, and verifies tokenVersion + user existence on every request - Global RolesGuard: authenticated routes require ADMIN unless widened via @Roles(...) - Admin SPA: session fully cookie-based, no token in localStorage; router guard restores session via /auth/me; axios auto-refreshes once on 401; stale localStorage keys cleaned up
This commit is contained in:
@@ -1,10 +1,28 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
|
||||
import type { AuthenticatedUser } from './strategies/jwt.strategy';
|
||||
|
||||
const ACCESS_TOKEN_COOKIE = 'ir_at';
|
||||
const REFRESH_TOKEN_COOKIE = 'ir_rt';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -25,10 +43,90 @@ export class AuthController {
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: 'Login and obtain a JWT' })
|
||||
@ApiOperation({ summary: 'Login and obtain access + refresh tokens' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
|
||||
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<LoginResponseDto> {
|
||||
const result = await this.authService.login(dto);
|
||||
// HttpOnly cookies are the primary session channel for the admin SPA
|
||||
// (XSS cannot read them). The access token is also returned in the
|
||||
// body for non-browser API clients.
|
||||
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/',
|
||||
});
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
// Only ever sent to /auth/refresh and /auth/logout
|
||||
path: '/auth',
|
||||
});
|
||||
// The refresh token deliberately stays HttpOnly-only.
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
} as unknown as LoginResponseDto;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: 'Rotate the refresh token cookie' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<LoginResponseDto> {
|
||||
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
|
||||
if (!token) {
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
||||
throw new UnauthorizedException('Missing refresh token');
|
||||
}
|
||||
const result = await this.authService.refresh(token);
|
||||
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/',
|
||||
});
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/auth',
|
||||
});
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
} as unknown as LoginResponseDto;
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Current authenticated user' })
|
||||
@ApiResponse({ status: 200, type: UserPublicDto })
|
||||
me(@Req() req: Request & { user: AuthenticatedUser }): Promise<UserPublicDto> {
|
||||
return this.authService.me(req.user.id) as unknown as Promise<UserPublicDto>;
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Revoke all tokens of the current user' })
|
||||
async logout(
|
||||
@Req() req: Request & { user: AuthenticatedUser },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ success: true }> {
|
||||
await this.authService.logout(req.user.id);
|
||||
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user