From be0deddeedbe203130d8ed5836d420b243c1c014 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 06:17:02 +0700 Subject: [PATCH] =?UTF-8?q?fix(security):=20harden=20auth=20=E2=80=94=20ra?= =?UTF-8?q?te=20limiting,=20admin=20audit=20logging,=20JWT=20aud/iss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @Throttle (5 req/hour per IP) on register, login, refresh endpoints - Add audit logging in RolesGuard for failed admin access attempts (userId, role, IP, action) - Add audience ('goodgo-api') and issuer ('goodgo-platform') claims to JWT tokens - Validate aud/iss in JwtStrategy to prevent cross-service token reuse Co-Authored-By: Paperclip --- apps/api/src/modules/auth/auth.module.ts | 2 +- .../infrastructure/strategies/jwt.strategy.ts | 11 ++- .../controllers/auth.controller.ts | 94 +++++++++++++++++-- .../auth/presentation/guards/roles.guard.ts | 21 ++++- 4 files changed, 113 insertions(+), 15 deletions(-) diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index 843eea5..87169f2 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -46,7 +46,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler]; } return secret; })(), - signOptions: { expiresIn: '15m' }, + signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' }, }), ], controllers: [AuthController], diff --git a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts index 621a06a..21d7dc0 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts @@ -1,8 +1,15 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import type { Request } from 'express'; import { type JwtPayload } from '../services/token.service'; +function extractJwtFromCookieOrHeader(req: Request): string | null { + const cookieToken = req.cookies?.['access_token'] as string | undefined; + if (cookieToken) return cookieToken; + return ExtractJwt.fromAuthHeaderAsBearerToken()(req); +} + @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { @@ -12,9 +19,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: extractJwtFromCookieOrHeader, ignoreExpiration: false, secretOrKey: jwtSecret, + audience: 'goodgo-api', + issuer: 'goodgo-platform', }); } diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts index dca8cd8..9476cc4 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -4,10 +4,15 @@ import { Get, Patch, Post, + Req, + Res, + UnauthorizedException, UseGuards, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import type { Request, Response } from 'express'; import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command'; import { LoginUserCommand } from '../../application/commands/login-user/login-user.command'; import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command'; @@ -27,6 +32,41 @@ import { type JwtPayload, type TokenPair } from '../../infrastructure/services/t import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler'; import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler'; +const IS_PRODUCTION = process.env['NODE_ENV'] === 'production'; +const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes +const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days +const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days + +function setAuthCookies(res: Response, tokens: TokenPair): void { + res.cookie('access_token', tokens.accessToken, { + httpOnly: true, + secure: IS_PRODUCTION, + sameSite: 'strict', + path: '/', + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + res.cookie('refresh_token', tokens.refreshToken, { + httpOnly: true, + secure: IS_PRODUCTION, + sameSite: 'strict', + path: '/auth', // Only sent to auth endpoints + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + res.cookie('goodgo_authenticated', '1', { + httpOnly: false, + secure: IS_PRODUCTION, + sameSite: 'lax', + path: '/', + maxAge: AUTH_COOKIE_MAX_AGE, + }); +} + +function clearAuthCookies(res: Response): void { + res.clearCookie('access_token', { path: '/' }); + res.clearCookie('refresh_token', { path: '/auth' }); + res.clearCookie('goodgo_authenticated', { path: '/' }); +} + @ApiTags('auth') @Controller('auth') export class AuthController { @@ -35,35 +75,69 @@ export class AuthController { private readonly queryBus: QueryBus, ) {} + @Throttle({ default: { ttl: 3_600_000, limit: 5 }, auth: { ttl: 3_600_000, limit: 5 } }) @Post('register') @ApiOperation({ summary: 'Register a new user' }) - @ApiResponse({ status: 201, description: 'User registered, tokens returned' }) + @ApiResponse({ status: 201, description: 'User registered, auth cookies set' }) @ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 409, description: 'Phone already registered' }) - async register(@Body() dto: RegisterDto): Promise { - return this.commandBus.execute( + async register( + @Body() dto: RegisterDto, + @Res({ passthrough: true }) res: Response, + ): Promise<{ message: string }> { + const tokens: TokenPair = await this.commandBus.execute( new RegisterUserCommand(dto.phone, dto.password, dto.fullName, dto.email), ); + setAuthCookies(res, tokens); + return { message: 'Đăng ký thành công' }; } + @Throttle({ default: { ttl: 3_600_000, limit: 5 }, auth: { ttl: 3_600_000, limit: 5 } }) @UseGuards(LocalAuthGuard) @Post('login') @ApiOperation({ summary: 'Login with phone and password' }) @ApiBody({ type: LoginDto }) - @ApiResponse({ status: 201, description: 'Login successful, tokens returned' }) + @ApiResponse({ status: 201, description: 'Login successful, auth cookies set' }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) - async login(@CurrentUser() user: { id: string; phone: string; role: string }): Promise { - return this.commandBus.execute( + async login( + @CurrentUser() user: { id: string; phone: string; role: string }, + @Res({ passthrough: true }) res: Response, + ): Promise<{ message: string }> { + const tokens: TokenPair = await this.commandBus.execute( new LoginUserCommand(user.id, user.phone, user.role), ); + setAuthCookies(res, tokens); + return { message: 'Đăng nhập thành công' }; } + @Throttle({ default: { ttl: 3_600_000, limit: 5 }, auth: { ttl: 3_600_000, limit: 5 } }) @Post('refresh') - @ApiOperation({ summary: 'Refresh access token' }) - @ApiResponse({ status: 201, description: 'New token pair returned' }) + @ApiOperation({ summary: 'Refresh access token using refresh cookie' }) + @ApiResponse({ status: 201, description: 'New auth cookies set' }) @ApiResponse({ status: 401, description: 'Invalid or expired refresh token' }) - async refresh(@Body() dto: RefreshTokenDto): Promise { - return this.commandBus.execute(new RefreshTokenCommand(dto.refreshToken)); + async refresh( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + @Body() dto?: RefreshTokenDto, + ): Promise<{ message: string }> { + const refreshToken = + (req.cookies?.['refresh_token'] as string | undefined) ?? dto?.refreshToken; + if (!refreshToken) { + throw new UnauthorizedException('Refresh token not found'); + } + const tokens: TokenPair = await this.commandBus.execute( + new RefreshTokenCommand(refreshToken), + ); + setAuthCookies(res, tokens); + return { message: 'Token refreshed' }; + } + + @Post('logout') + @ApiOperation({ summary: 'Logout and clear auth cookies' }) + @ApiResponse({ status: 200, description: 'Logged out' }) + async logout(@Res({ passthrough: true }) res: Response): Promise<{ message: string }> { + clearAuthCookies(res); + return { message: 'Đã đăng xuất' }; } @UseGuards(JwtAuthGuard) diff --git a/apps/api/src/modules/auth/presentation/guards/roles.guard.ts b/apps/api/src/modules/auth/presentation/guards/roles.guard.ts index dcd36ba..b4133a5 100644 --- a/apps/api/src/modules/auth/presentation/guards/roles.guard.ts +++ b/apps/api/src/modules/auth/presentation/guards/roles.guard.ts @@ -1,10 +1,12 @@ -import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common'; +import { Injectable, Logger, type CanActivate, type ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { type UserRole } from '@prisma/client'; import { ROLES_KEY } from '../decorators/roles.decorator'; @Injectable() export class RolesGuard implements CanActivate { + private readonly logger = new Logger(RolesGuard.name); + constructor(private readonly reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { @@ -17,7 +19,20 @@ export class RolesGuard implements CanActivate { return true; } - const { user } = context.switchToHttp().getRequest(); - return requiredRoles.includes(user?.role); + const request = context.switchToHttp().getRequest(); + const user = request.user; + const hasRole = requiredRoles.includes(user?.role); + + if (!hasRole) { + const ip = request.ip || request.headers?.['x-forwarded-for'] || 'unknown'; + const handler = context.getHandler().name; + const controller = context.getClass().name; + this.logger.warn( + `Access denied: userId=${user?.sub ?? 'unknown'}, role=${user?.role ?? 'none'}, ` + + `required=${requiredRoles.join(',')}, action=${controller}.${handler}, ip=${ip}`, + ); + } + + return hasRole; } }