import { Body, Controller, Get, Patch, Post, Req, Res, UseGuards, } from '@nestjs/common'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { type Request, type Response } from 'express'; import { EndpointRateLimit, EndpointRateLimitGuard, UnauthorizedException, } from '@modules/shared'; import { GenerateKycUploadUrlsCommand } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command'; import { LoginUserCommand } from '../../application/commands/login-user/login-user.command'; import { type LoginResult } from '../../application/commands/login-user/login-user.handler'; import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command'; import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command'; import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command'; import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command'; import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler'; import { VerifyEmailChangeCommand } from '../../application/commands/verify-email-change/verify-email-change.command'; import { type VerifyEmailChangeResultDto } from '../../application/commands/verify-email-change/verify-email-change.handler'; import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command'; import { VerifyPhoneChangeCommand } from '../../application/commands/verify-phone-change/verify-phone-change.command'; import { type VerifyPhoneChangeResultDto } from '../../application/commands/verify-phone-change/verify-phone-change.handler'; import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler'; import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.query'; import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler'; import { GetProfileQuery } from '../../application/queries/get-profile/get-profile.query'; import { type TokenService, type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service'; import { type LocalStrategyResult } from '../../infrastructure/strategies/local.strategy'; import { CurrentUser } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; import { type GenerateKycUploadUrlsDto } from '../dto/generate-kyc-upload-urls.dto'; import { LoginDto } from '../dto/login.dto'; import { type RefreshTokenDto } from '../dto/refresh-token.dto'; import { type RegisterDto } from '../dto/register.dto'; import { type SubmitKycDto } from '../dto/submit-kyc.dto'; import { type UpdateProfileDto } from '../dto/update-profile.dto'; import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto'; import { type VerifyKycDto } from '../dto/verify-kyc.dto'; import { type VerifyPhoneChangeDto } from '../dto/verify-phone-change.dto'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { LocalAuthGuard } from '../guards/local-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; const IS_PRODUCTION = process.env['NODE_ENV'] === 'production'; const IS_TEST = process.env['NODE_ENV'] === 'test'; const AUTH_RATE_LIMIT = IS_TEST ? 10_000 : 5; 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 const SAME_SITE = IS_PRODUCTION ? 'strict' : 'lax'; function setAuthCookies(res: Response, tokens: TokenPair): void { res.cookie('access_token', tokens.accessToken, { httpOnly: true, secure: IS_PRODUCTION, sameSite: SAME_SITE, path: '/', maxAge: ACCESS_TOKEN_MAX_AGE, }); res.cookie('refresh_token', tokens.refreshToken, { httpOnly: true, secure: IS_PRODUCTION, sameSite: SAME_SITE, path: '/', 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: '/' }); res.clearCookie('goodgo_authenticated', { path: '/' }); } @ApiTags('auth') @Controller('auth') export class AuthController { constructor( private readonly commandBus: CommandBus, private readonly queryBus: QueryBus, private readonly tokenService: TokenService, ) {} @Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } }) @EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' }) @UseGuards(EndpointRateLimitGuard) @Post('register') @ApiOperation({ summary: 'Register a new user' }) @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, @Res({ passthrough: true }) res: Response, ): Promise<{ message: string; accessToken: string; refreshToken: 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', accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, }; } @Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } }) @EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' }) @UseGuards(EndpointRateLimitGuard, LocalAuthGuard) @Post('login') @ApiOperation({ summary: 'Login with phone and password' }) @ApiBody({ type: LoginDto }) @ApiResponse({ status: 201, description: 'Login successful, auth cookies set (or MFA challenge returned)' }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) async login( @CurrentUser() user: LocalStrategyResult, @Res({ passthrough: true }) res: Response, ): Promise<{ message: string; accessToken?: string; refreshToken?: string; requiresMfa?: boolean; challengeId?: string }> { const result: LoginResult = await this.commandBus.execute( new LoginUserCommand(user.id, user.phone, user.role, user.isMfaRequired), ); if (result.requiresMfa) { return { message: 'Yêu cầu xác thực MFA', requiresMfa: true, challengeId: result.challengeId, }; } setAuthCookies(res, result.tokens!); return { message: 'Đăng nhập thành công', accessToken: result.tokens!.accessToken, refreshToken: result.tokens!.refreshToken, }; } @Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } }) @Post('refresh') @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( @Req() req: Request, @Res({ passthrough: true }) res: Response, @Body() dto?: RefreshTokenDto, ): Promise<{ message: string; accessToken: string; refreshToken: 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', accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, }; } @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' }; } @Post('exchange-token') @ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' }) @ApiResponse({ status: 201, description: 'Auth cookies set' }) @ApiResponse({ status: 401, description: 'Invalid access token' }) async exchangeToken( @Body() body: { accessToken: string; refreshToken: string; expiresIn?: number }, @Res({ passthrough: true }) res: Response, ): Promise<{ message: string }> { const payload = this.tokenService.verifyAccessToken(body.accessToken); if (!payload) { throw new UnauthorizedException('Invalid access token'); } setAuthCookies(res, { accessToken: body.accessToken, refreshToken: body.refreshToken, expiresIn: body.expiresIn ?? 900, }); return { message: 'Auth cookies set' }; } @UseGuards(JwtAuthGuard) @Get('profile') @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Get current user profile' }) @ApiResponse({ status: 200, description: 'User profile returned' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async getProfile(@CurrentUser() user: JwtPayload): Promise { return this.queryBus.execute(new GetProfileQuery(user.sub)); } @UseGuards(JwtAuthGuard) @Patch('profile') @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Update current user profile' }) @ApiResponse({ status: 200, description: 'Profile updated successfully' }) @ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 409, description: 'Email already in use' }) async updateProfile( @CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto, ): Promise<{ message: string; data: UpdateProfileResultDto }> { const result: UpdateProfileResultDto = await this.commandBus.execute( new UpdateProfileCommand(user.sub, dto.fullName, dto.avatarUrl, dto.email, dto.phoneNumber), ); return { message: 'Cập nhật hồ sơ thành công', data: result }; } @UseGuards(JwtAuthGuard) @Post('profile/verify-phone') @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Verify phone number change with SMS OTP code' }) @ApiResponse({ status: 201, description: 'Phone number changed successfully' }) @ApiResponse({ status: 400, description: 'Invalid or expired OTP code' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 409, description: 'Phone number already in use' }) async verifyPhoneChange( @CurrentUser() user: JwtPayload, @Body() dto: VerifyPhoneChangeDto, ): Promise<{ message: string; data: VerifyPhoneChangeResultDto }> { const result: VerifyPhoneChangeResultDto = await this.commandBus.execute( new VerifyPhoneChangeCommand(user.sub, dto.code), ); return { message: 'Số điện thoại đã được cập nhật thành công', data: result }; } @UseGuards(JwtAuthGuard) @Post('profile/verify-email') @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Verify email change with OTP code' }) @ApiResponse({ status: 201, description: 'Email changed successfully' }) @ApiResponse({ status: 400, description: 'Invalid or expired OTP code' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 409, description: 'Email already in use' }) async verifyEmailChange( @CurrentUser() user: JwtPayload, @Body() dto: VerifyEmailChangeDto, ): Promise<{ message: string; data: VerifyEmailChangeResultDto }> { const result: VerifyEmailChangeResultDto = await this.commandBus.execute( new VerifyEmailChangeCommand(user.sub, dto.code), ); return { message: 'Email đã được cập nhật thành công', data: result }; } @UseGuards(JwtAuthGuard) @Get('profile/agent') @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Get agent profile for current user' }) @ApiResponse({ status: 200, description: 'Agent profile returned (null if not an agent)' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async getAgentProfile(@CurrentUser() user: JwtPayload): Promise { return this.queryBus.execute(new GetAgentByUserIdQuery(user.sub)); } @UseGuards(JwtAuthGuard) @Post('kyc/upload-urls') @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Generate presigned upload URLs for KYC images' }) @ApiResponse({ status: 201, description: 'Presigned URLs generated' }) @ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async generateKycUploadUrls( @Body() body: GenerateKycUploadUrlsDto, @CurrentUser() user: JwtPayload, ): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> { return this.commandBus.execute( new GenerateKycUploadUrlsCommand(user.sub, body.files), ); } @UseGuards(JwtAuthGuard) @Post('kyc/submit') @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Submit KYC documents with presigned image URLs' }) @ApiResponse({ status: 201, description: 'KYC documents submitted successfully' }) @ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async submitKyc( @Body() body: SubmitKycDto, @CurrentUser() user: JwtPayload, ): Promise<{ message: string }> { return this.commandBus.execute( new SubmitKycCommand( user.sub, body.documentType, body.documentNumber, undefined, undefined, undefined, { frontImageUrl: body.frontImageUrl, backImageUrl: body.backImageUrl, selfieUrl: body.selfieUrl, }, ), ); } @UseGuards(JwtAuthGuard, RolesGuard) @Roles('ADMIN') @Patch('kyc') @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Verify user KYC (admin only)' }) @ApiResponse({ status: 200, description: 'KYC status updated' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden — admin only' }) async verifyKyc( @Body() dto: VerifyKycDto & { userId: string }, ): Promise<{ message: string }> { await this.commandBus.execute( new VerifyKycCommand(dto.userId, dto.kycStatus, dto.kycData), ); return { message: 'KYC status đã được cập nhật' }; } }