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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user