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;
|
return secret;
|
||||||
})(),
|
})(),
|
||||||
signOptions: { expiresIn: '15m' },
|
signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { type JwtPayload } from '../services/token.service';
|
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()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -12,9 +19,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: extractJwtFromCookieOrHeader,
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: jwtSecret,
|
secretOrKey: jwtSecret,
|
||||||
|
audience: 'goodgo-api',
|
||||||
|
issuer: 'goodgo-platform',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
|
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
|
||||||
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
|
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
|
||||||
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.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 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';
|
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')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -35,35 +75,69 @@ export class AuthController {
|
|||||||
private readonly queryBus: QueryBus,
|
private readonly queryBus: QueryBus,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Throttle({ default: { ttl: 3_600_000, limit: 5 }, auth: { ttl: 3_600_000, limit: 5 } })
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@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: 400, description: 'Validation error' })
|
||||||
@ApiResponse({ status: 409, description: 'Phone already registered' })
|
@ApiResponse({ status: 409, description: 'Phone already registered' })
|
||||||
async register(@Body() dto: RegisterDto): Promise<TokenPair> {
|
async register(
|
||||||
return this.commandBus.execute(
|
@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),
|
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)
|
@UseGuards(LocalAuthGuard)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with phone and password' })
|
@ApiOperation({ summary: 'Login with phone and password' })
|
||||||
@ApiBody({ type: LoginDto })
|
@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' })
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
async login(@CurrentUser() user: { id: string; phone: string; role: string }): Promise<TokenPair> {
|
async login(
|
||||||
return this.commandBus.execute(
|
@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),
|
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')
|
@Post('refresh')
|
||||||
@ApiOperation({ summary: 'Refresh access token' })
|
@ApiOperation({ summary: 'Refresh access token using refresh cookie' })
|
||||||
@ApiResponse({ status: 201, description: 'New token pair returned' })
|
@ApiResponse({ status: 201, description: 'New auth cookies set' })
|
||||||
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token' })
|
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token' })
|
||||||
async refresh(@Body() dto: RefreshTokenDto): Promise<TokenPair> {
|
async refresh(
|
||||||
return this.commandBus.execute(new RefreshTokenCommand(dto.refreshToken));
|
@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)
|
@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 { Reflector } from '@nestjs/core';
|
||||||
import { type UserRole } from '@prisma/client';
|
import { type UserRole } from '@prisma/client';
|
||||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RolesGuard implements CanActivate {
|
export class RolesGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(RolesGuard.name);
|
||||||
|
|
||||||
constructor(private readonly reflector: Reflector) {}
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
@@ -17,7 +19,20 @@ export class RolesGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
return requiredRoles.includes(user?.role);
|
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