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:
Ho Ngoc Hai
2026-04-11 23:43:20 +07:00
parent 9e2bf9a4b5
commit 1fbe2f4e73
131 changed files with 11436 additions and 2595 deletions

View File

@@ -0,0 +1,6 @@
export class DisableMfaCommand {
constructor(
public readonly userId: string,
public readonly totpCode: string,
) {}
}

View File

@@ -0,0 +1,47 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, UnauthorizedException, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { DisableMfaCommand } from './disable-mfa.command';
@CommandHandler(DisableMfaCommand)
export class DisableMfaHandler implements ICommandHandler<DisableMfaCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly mfaService: MfaService,
private readonly logger: LoggerService,
) {}
async execute(command: DisableMfaCommand): Promise<{ message: string }> {
try {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new ValidationException('Người dùng không tồn tại');
}
if (!user.totpEnabled || !user.totpSecret) {
throw new ValidationException('MFA chưa được bật');
}
// Require current TOTP code to disable MFA
const isValid = await this.mfaService.verifyTotp(command.totpCode, user.totpSecret);
if (!isValid) {
throw new UnauthorizedException('Mã TOTP không hợp lệ');
}
// Disable MFA
await this.userRepo.updateMfaDisabled(command.userId);
return { message: 'MFA đã được tắt thành công' };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to disable MFA: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể tắt MFA');
}
}
}

View File

@@ -3,5 +3,6 @@ export class LoginUserCommand {
public readonly userId: string,
public readonly phone: string,
public readonly role: string,
public readonly isMfaRequired: boolean = false,
) {}
}

View File

@@ -1,23 +1,66 @@
import { InternalServerErrorException } from '@nestjs/common';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { type LoggerService, DomainException } from '@modules/shared';
import {
MFA_CHALLENGE_REPOSITORY,
type IMfaChallengeRepository,
} from '../../../domain/repositories/mfa-challenge.repository';
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { LoginUserCommand } from './login-user.command';
const MFA_CHALLENGE_TTL_MINUTES = 5;
export interface LoginResult {
requiresMfa: boolean;
challengeId?: string;
tokens?: TokenPair;
}
@CommandHandler(LoginUserCommand)
export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
constructor(
private readonly tokenService: TokenService,
@Inject(MFA_CHALLENGE_REPOSITORY)
private readonly challengeRepo: IMfaChallengeRepository,
private readonly logger: LoggerService,
) {}
async execute(command: LoginUserCommand): Promise<TokenPair> {
async execute(command: LoginUserCommand): Promise<LoginResult> {
try {
return await this.tokenService.generateTokenPair({
// If MFA is required, create a challenge instead of tokens
if (command.isMfaRequired) {
const challengeId = createId();
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + MFA_CHALLENGE_TTL_MINUTES);
await this.challengeRepo.create({
id: challengeId,
userId: command.userId,
type: 'totp',
attemptCount: 0,
maxAttempts: 5,
isVerified: false,
expiresAt,
});
return {
requiresMfa: true,
challengeId,
};
}
// No MFA — issue tokens directly
const tokens = await this.tokenService.generateTokenPair({
sub: command.userId,
phone: command.phone,
role: command.role,
});
return {
requiresMfa: false,
tokens,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
@@ -29,3 +72,4 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
}
}
}

View File

@@ -0,0 +1,3 @@
export class SetupMfaCommand {
constructor(public readonly userId: string) {}
}

View File

@@ -0,0 +1,55 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService, type MfaSetupResult } from '../../../infrastructure/services/mfa.service';
import { SetupMfaCommand } from './setup-mfa.command';
export interface SetupMfaResultDto {
secret: string;
qrCodeDataUrl: string;
otpauthUrl: string;
}
@CommandHandler(SetupMfaCommand)
export class SetupMfaHandler implements ICommandHandler<SetupMfaCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly mfaService: MfaService,
private readonly logger: LoggerService,
) {}
async execute(command: SetupMfaCommand): Promise<SetupMfaResultDto> {
try {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new ValidationException('Người dùng không tồn tại');
}
if (user.totpEnabled) {
throw new ValidationException('MFA đã được bật. Vui lòng tắt trước khi thiết lập lại');
}
// Generate TOTP setup (secret + QR code)
const identifier = user.email?.value ?? user.phone.value;
const setup: MfaSetupResult = await this.mfaService.generateSetup(identifier);
// Store secret temporarily (not enabled yet — user must verify first)
await this.userRepo.updateMfaSecret(command.userId, setup.secret);
return {
secret: setup.secret,
qrCodeDataUrl: setup.qrCodeDataUrl,
otpauthUrl: setup.otpauthUrl,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to setup MFA: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể thiết lập MFA');
}
}
}

View File

@@ -0,0 +1,6 @@
export class UseBackupCodeCommand {
constructor(
public readonly challengeId: string,
public readonly backupCode: string,
) {}
}

View File

@@ -0,0 +1,91 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared';
import {
MFA_CHALLENGE_REPOSITORY,
type IMfaChallengeRepository,
} from '../../../domain/repositories/mfa-challenge.repository';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { UseBackupCodeCommand } from './use-backup-code.command';
@CommandHandler(UseBackupCodeCommand)
export class UseBackupCodeHandler implements ICommandHandler<UseBackupCodeCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
@Inject(MFA_CHALLENGE_REPOSITORY) private readonly challengeRepo: IMfaChallengeRepository,
private readonly mfaService: MfaService,
private readonly tokenService: TokenService,
private readonly logger: LoggerService,
) {}
async execute(command: UseBackupCodeCommand): Promise<TokenPair & { remainingBackupCodes: number }> {
try {
// Find and validate the challenge
const challenge = await this.challengeRepo.findById(command.challengeId);
if (!challenge) {
throw new UnauthorizedException('Phiên xác thực MFA không tồn tại hoặc đã hết hạn');
}
if (challenge.isVerified) {
throw new UnauthorizedException('Phiên xác thực MFA đã được sử dụng');
}
if (challenge.expiresAt < new Date()) {
throw new UnauthorizedException('Phiên xác thực MFA đã hết hạn');
}
if (challenge.attemptCount >= challenge.maxAttempts) {
throw new UnauthorizedException('Đã vượt quá số lần thử. Vui lòng đăng nhập lại');
}
// Look up the user
const user = await this.userRepo.findById(challenge.userId);
if (!user || !user.totpEnabled) {
throw new UnauthorizedException('MFA chưa được thiết lập cho tài khoản này');
}
// Verify backup code
const codeIndex = this.mfaService.verifyBackupCode(
command.backupCode,
user.totpBackupCodes,
);
if (codeIndex === -1) {
await this.challengeRepo.incrementAttempts(command.challengeId);
const remaining = challenge.maxAttempts - challenge.attemptCount - 1;
throw new UnauthorizedException(
`Mã backup không hợp lệ. Còn ${remaining} lần thử`,
);
}
// Consume the backup code (remove from array)
const updatedCodes = user.totpBackupCodes.filter((_, i) => i !== codeIndex);
await this.userRepo.updateBackupCodes(challenge.userId, updatedCodes);
// Mark the challenge as verified
await this.challengeRepo.markVerified(command.challengeId);
// Generate token pair (login complete)
const tokens = await this.tokenService.generateTokenPair({
sub: user.id,
phone: user.phone.value,
role: user.role,
});
return {
...tokens,
remainingBackupCodes: updatedCodes.length,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to use backup code: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể xác thực bằng mã backup');
}
}
}

View File

@@ -0,0 +1,6 @@
export class VerifyMfaChallengeCommand {
constructor(
public readonly challengeId: string,
public readonly totpCode: string,
) {}
}

View File

@@ -0,0 +1,78 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared';
import {
MFA_CHALLENGE_REPOSITORY,
type IMfaChallengeRepository,
} from '../../../domain/repositories/mfa-challenge.repository';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { VerifyMfaChallengeCommand } from './verify-mfa-challenge.command';
@CommandHandler(VerifyMfaChallengeCommand)
export class VerifyMfaChallengeHandler implements ICommandHandler<VerifyMfaChallengeCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
@Inject(MFA_CHALLENGE_REPOSITORY) private readonly challengeRepo: IMfaChallengeRepository,
private readonly mfaService: MfaService,
private readonly tokenService: TokenService,
private readonly logger: LoggerService,
) {}
async execute(command: VerifyMfaChallengeCommand): Promise<TokenPair> {
try {
// Find and validate the challenge
const challenge = await this.challengeRepo.findById(command.challengeId);
if (!challenge) {
throw new UnauthorizedException('Phiên xác thực MFA không tồn tại hoặc đã hết hạn');
}
if (challenge.isVerified) {
throw new UnauthorizedException('Phiên xác thực MFA đã được sử dụng');
}
if (challenge.expiresAt < new Date()) {
throw new UnauthorizedException('Phiên xác thực MFA đã hết hạn');
}
if (challenge.attemptCount >= challenge.maxAttempts) {
throw new UnauthorizedException('Đã vượt quá số lần thử. Vui lòng đăng nhập lại');
}
// Look up the user
const user = await this.userRepo.findById(challenge.userId);
if (!user || !user.totpSecret || !user.totpEnabled) {
throw new UnauthorizedException('MFA chưa được thiết lập cho tài khoản này');
}
// Verify the TOTP code
const isValid = await this.mfaService.verifyTotp(command.totpCode, user.totpSecret);
if (!isValid) {
await this.challengeRepo.incrementAttempts(command.challengeId);
const remaining = challenge.maxAttempts - challenge.attemptCount - 1;
throw new UnauthorizedException(
`Mã TOTP không hợp lệ. Còn ${remaining} lần thử`,
);
}
// Mark the challenge as verified
await this.challengeRepo.markVerified(command.challengeId);
// Generate token pair (login complete)
return this.tokenService.generateTokenPair({
sub: user.id,
phone: user.phone.value,
role: user.role,
});
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to verify MFA challenge: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể xác thực MFA');
}
}
}

View File

@@ -0,0 +1,6 @@
export class VerifyMfaSetupCommand {
constructor(
public readonly userId: string,
public readonly totpCode: string,
) {}
}

View File

@@ -0,0 +1,69 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { VerifyMfaSetupCommand } from './verify-mfa-setup.command';
export interface VerifyMfaSetupResultDto {
backupCodes: string[];
backupCodeCount: number;
message: string;
}
@CommandHandler(VerifyMfaSetupCommand)
export class VerifyMfaSetupHandler implements ICommandHandler<VerifyMfaSetupCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly mfaService: MfaService,
private readonly logger: LoggerService,
) {}
async execute(command: VerifyMfaSetupCommand): Promise<VerifyMfaSetupResultDto> {
try {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new ValidationException('Người dùng không tồn tại');
}
if (user.totpEnabled) {
throw new ValidationException('MFA đã được bật');
}
if (!user.totpSecret) {
throw new ValidationException('Chưa thiết lập MFA. Vui lòng gọi /auth/mfa/setup trước');
}
// Verify the TOTP code against the stored (pending) secret
const isValid = await this.mfaService.verifyTotp(command.totpCode, user.totpSecret);
if (!isValid) {
throw new ValidationException('Mã TOTP không hợp lệ. Vui lòng thử lại');
}
// Generate backup codes
const { plainCodes, hashedCodes } = this.mfaService.generateBackupCodes();
// Enable MFA
await this.userRepo.updateMfaEnabled(
command.userId,
true,
user.totpSecret,
hashedCodes,
);
return {
backupCodes: plainCodes,
backupCodeCount: plainCodes.length,
message: 'MFA đã được bật thành công. Vui lòng lưu mã backup an toàn',
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to verify MFA setup: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể xác nhận thiết lập MFA');
}
}
}