feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests
- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow - Add PII field encryption middleware with AES-256-GCM and deterministic search hashes - Add agents, inquiries, and leads domain modules with entities, events, value objects - Add web dashboard pages for inquiries and leads with detail dialogs - Add 30+ component tests (valuation, charts, listings, search, providers, UI) - Add Prisma migrations for encryption hash columns and MFA TOTP support - Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes) - Update dependencies and lock file - Clean up obsolete exploration/QA docs, add audit documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type 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 { type 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 { type VerifyMfaSetupResultDto } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.handler';
|
||||
import { type MfaStatusDto } from '../../application/queries/get-mfa-status/get-mfa-status.handler';
|
||||
import { GetMfaStatusQuery } from '../../application/queries/get-mfa-status/get-mfa-status.query';
|
||||
import { type TokenService, type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
import {
|
||||
type VerifyMfaSetupDto,
|
||||
type VerifyMfaChallengeDto,
|
||||
type UseBackupCodeDto,
|
||||
type 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user