Files
goodgo-platform/apps/api/src/modules/auth/presentation/controllers/mfa.controller.ts
Ho Ngoc Hai c658e540f0 fix(api): remove type-only imports of injectable classes to fix NestJS DI
Type-only imports (`import { type X }`) strip runtime type metadata
needed by NestJS dependency injection via reflect-metadata. This caused
`UnknownDependenciesException` errors where constructor parameters
resolved to `Function` instead of the actual class.

Fixed 129 files across all modules:
- Services (LoggerService, PrismaService, CacheService, etc.)
- CQRS buses (EventBus, QueryBus, CommandBus)
- DTOs used with @Body()/@Query() decorators in controllers
- Payment gateway services and search repositories

Also fixed E2E test infrastructure:
- auth.fixture.ts: use destructuring pattern for Playwright fixture
- global-teardown.ts: correct column names (Lead.agentId, Transaction.buyerId)
- inquiries.spec.ts: flexible response property checks
- payments-callback.spec.ts: accept 500 for unknown provider

All 111 API E2E tests now pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 20:43:35 +07:00

172 lines
6.8 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { type Response } from 'express';
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
import { DisableMfaCommand } from '../../application/commands/disable-mfa/disable-mfa.command';
import { SetupMfaCommand } from '../../application/commands/setup-mfa/setup-mfa.command';
import { SetupMfaResultDto } from '../../application/commands/setup-mfa/setup-mfa.handler';
import { UseBackupCodeCommand } from '../../application/commands/use-backup-code/use-backup-code.command';
import { VerifyMfaChallengeCommand } from '../../application/commands/verify-mfa-challenge/verify-mfa-challenge.command';
import { VerifyMfaSetupCommand } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.command';
import { VerifyMfaSetupResultDto } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.handler';
import { MfaStatusDto } from '../../application/queries/get-mfa-status/get-mfa-status.handler';
import { GetMfaStatusQuery } from '../../application/queries/get-mfa-status/get-mfa-status.query';
import { TokenService, type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service';
import { CurrentUser } from '../decorators/current-user.decorator';
import {
VerifyMfaSetupDto,
VerifyMfaChallengeDto,
UseBackupCodeDto,
DisableMfaDto,
} from '../dto/mfa.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
const IS_TEST = process.env['NODE_ENV'] === 'test';
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
const MFA_RATE_LIMIT = IS_TEST ? 10_000 : 5;
const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000;
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
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',
maxAge: REFRESH_TOKEN_MAX_AGE,
});
res.cookie('goodgo_authenticated', '1', {
httpOnly: false,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/',
maxAge: AUTH_COOKIE_MAX_AGE,
});
}
@ApiTags('auth')
@Controller('auth/mfa')
export class MfaController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly tokenService: TokenService,
) {}
@UseGuards(JwtAuthGuard)
@Post('setup')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Generate TOTP secret and QR code for MFA setup' })
@ApiResponse({ status: 201, description: 'TOTP secret and QR code generated' })
@ApiResponse({ status: 400, description: 'MFA already enabled' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async setup(@CurrentUser() user: JwtPayload): Promise<SetupMfaResultDto> {
return this.commandBus.execute(new SetupMfaCommand(user.sub));
}
@UseGuards(JwtAuthGuard)
@Post('verify-setup')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Verify TOTP setup with first code and enable MFA' })
@ApiResponse({ status: 201, description: 'MFA enabled, backup codes returned' })
@ApiResponse({ status: 400, description: 'Invalid TOTP code or MFA not set up' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async verifySetup(
@CurrentUser() user: JwtPayload,
@Body() dto: VerifyMfaSetupDto,
): Promise<VerifyMfaSetupResultDto> {
return this.commandBus.execute(
new VerifyMfaSetupCommand(user.sub, dto.totpCode),
);
}
@Throttle({ default: { ttl: 3_600_000, limit: MFA_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('challenge')
@ApiOperation({ summary: 'Verify TOTP code during login MFA challenge' })
@ApiResponse({ status: 201, description: 'MFA verified, auth tokens returned' })
@ApiResponse({ status: 401, description: 'Invalid TOTP code or expired challenge' })
async verifyChallenge(
@Body() dto: VerifyMfaChallengeDto,
@Res({ passthrough: true }) res: Response,
): Promise<{ message: string; accessToken: string; refreshToken: string }> {
const tokens: TokenPair = await this.commandBus.execute(
new VerifyMfaChallengeCommand(dto.challengeId, dto.totpCode),
);
setAuthCookies(res, tokens);
return {
message: 'Xác thực MFA thành công',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
@Throttle({ default: { ttl: 3_600_000, limit: MFA_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('backup-codes')
@ApiOperation({ summary: 'Use a backup code during MFA challenge' })
@ApiResponse({ status: 201, description: 'Backup code accepted, auth tokens returned' })
@ApiResponse({ status: 401, description: 'Invalid backup code or expired challenge' })
async useBackupCode(
@Body() dto: UseBackupCodeDto,
@Res({ passthrough: true }) res: Response,
): Promise<{ message: string; accessToken: string; refreshToken: string; remainingBackupCodes: number }> {
const result = await this.commandBus.execute(
new UseBackupCodeCommand(dto.challengeId, dto.backupCode),
);
setAuthCookies(res, result);
return {
message: 'Xác thực bằng mã backup thành công',
accessToken: result.accessToken,
refreshToken: result.refreshToken,
remainingBackupCodes: result.remainingBackupCodes,
};
}
@UseGuards(JwtAuthGuard)
@Delete()
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Disable MFA (requires current TOTP code)' })
@ApiResponse({ status: 200, description: 'MFA disabled' })
@ApiResponse({ status: 400, description: 'MFA not enabled' })
@ApiResponse({ status: 401, description: 'Invalid TOTP code' })
async disable(
@CurrentUser() user: JwtPayload,
@Body() dto: DisableMfaDto,
): Promise<{ message: string }> {
return this.commandBus.execute(
new DisableMfaCommand(user.sub, dto.totpCode),
);
}
@UseGuards(JwtAuthGuard)
@Get('status')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get MFA status for current user' })
@ApiResponse({ status: 200, description: 'MFA status returned' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getStatus(@CurrentUser() user: JwtPayload): Promise<MfaStatusDto> {
return this.queryBus.execute(new GetMfaStatusQuery(user.sub));
}
}