fix(security): harden auth — rate limiting, admin audit logging, JWT aud/iss

- 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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 06:17:02 +07:00
parent e60b95cdec
commit be0deddeed
4 changed files with 113 additions and 15 deletions

View File

@@ -46,7 +46,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
}
return secret;
})(),
signOptions: { expiresIn: '15m' },
signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' },
}),
],
controllers: [AuthController],

View File

@@ -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',
});
}

View File

@@ -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<TokenPair> {
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<TokenPair> {
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<TokenPair> {
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)

View File

@@ -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;
}
}