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:
@@ -46,7 +46,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
||||
}
|
||||
return secret;
|
||||
})(),
|
||||
signOptions: { expiresIn: '15m' },
|
||||
signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user