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:
@@ -4,6 +4,7 @@ import { LoginUserHandler } from '../commands/login-user/login-user.handler';
|
||||
describe('LoginUserHandler', () => {
|
||||
let handler: LoginUserHandler;
|
||||
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
|
||||
let mockChallengeRepo: { create: ReturnType<typeof vi.fn> };
|
||||
|
||||
const tokenPair = {
|
||||
accessToken: 'access-jwt',
|
||||
@@ -13,14 +14,15 @@ describe('LoginUserHandler', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
|
||||
handler = new LoginUserHandler(mockTokenService as any);
|
||||
mockChallengeRepo = { create: vi.fn().mockResolvedValue({}) };
|
||||
handler = new LoginUserHandler(mockTokenService as any, mockChallengeRepo as any);
|
||||
});
|
||||
|
||||
it('generates token pair with correct payload', async () => {
|
||||
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER');
|
||||
it('generates token pair with correct payload when MFA not required', async () => {
|
||||
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', false);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result).toEqual(tokenPair);
|
||||
expect(result).toEqual({ requiresMfa: false, tokens: tokenPair });
|
||||
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||
sub: 'user-1',
|
||||
phone: '0912345678',
|
||||
@@ -28,6 +30,25 @@ describe('LoginUserHandler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates MFA challenge when MFA is required', async () => {
|
||||
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', true);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.requiresMfa).toBe(true);
|
||||
expect(result.challengeId).toBeDefined();
|
||||
expect(result.tokens).toBeUndefined();
|
||||
expect(mockTokenService.generateTokenPair).not.toHaveBeenCalled();
|
||||
expect(mockChallengeRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
type: 'totp',
|
||||
attemptCount: 0,
|
||||
maxAttempts: 5,
|
||||
isVerified: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes AGENT role correctly', async () => {
|
||||
const command = new LoginUserCommand('user-2', '0987654321', 'AGENT');
|
||||
await handler.execute(command);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DisableMfaCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly totpCode: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ export class LoginUserCommand {
|
||||
public readonly userId: string,
|
||||
public readonly phone: string,
|
||||
public readonly role: string,
|
||||
public readonly isMfaRequired: boolean = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export class SetupMfaCommand {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class UseBackupCodeCommand {
|
||||
constructor(
|
||||
public readonly challengeId: string,
|
||||
public readonly backupCode: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class VerifyMfaChallengeCommand {
|
||||
constructor(
|
||||
public readonly challengeId: string,
|
||||
public readonly totpCode: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class VerifyMfaSetupCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly totpCode: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export { RegisterUserCommand } from './commands/register-user/register-user.command';
|
||||
export { RegisterUserHandler } from './commands/register-user/register-user.handler';
|
||||
export { LoginUserCommand } from './commands/login-user/login-user.command';
|
||||
export { LoginUserHandler } from './commands/login-user/login-user.handler';
|
||||
export { LoginUserHandler, type LoginResult } from './commands/login-user/login-user.handler';
|
||||
export { RefreshTokenCommand } from './commands/refresh-token/refresh-token.command';
|
||||
export { RefreshTokenHandler } from './commands/refresh-token/refresh-token.handler';
|
||||
export { VerifyKycCommand } from './commands/verify-kyc/verify-kyc.command';
|
||||
@@ -10,3 +10,16 @@ export { GetProfileQuery } from './queries/get-profile/get-profile.query';
|
||||
export { GetProfileHandler, type UserProfileDto } from './queries/get-profile/get-profile.handler';
|
||||
export { GetAgentByUserIdQuery } from './queries/get-agent-by-user-id/get-agent-by-user-id.query';
|
||||
export { GetAgentByUserIdHandler, type AgentDto } from './queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
||||
// MFA
|
||||
export { SetupMfaCommand } from './commands/setup-mfa/setup-mfa.command';
|
||||
export { SetupMfaHandler, type SetupMfaResultDto } from './commands/setup-mfa/setup-mfa.handler';
|
||||
export { VerifyMfaSetupCommand } from './commands/verify-mfa-setup/verify-mfa-setup.command';
|
||||
export { VerifyMfaSetupHandler, type VerifyMfaSetupResultDto } from './commands/verify-mfa-setup/verify-mfa-setup.handler';
|
||||
export { VerifyMfaChallengeCommand } from './commands/verify-mfa-challenge/verify-mfa-challenge.command';
|
||||
export { VerifyMfaChallengeHandler } from './commands/verify-mfa-challenge/verify-mfa-challenge.handler';
|
||||
export { DisableMfaCommand } from './commands/disable-mfa/disable-mfa.command';
|
||||
export { DisableMfaHandler } from './commands/disable-mfa/disable-mfa.handler';
|
||||
export { UseBackupCodeCommand } from './commands/use-backup-code/use-backup-code.command';
|
||||
export { UseBackupCodeHandler } from './commands/use-backup-code/use-backup-code.handler';
|
||||
export { GetMfaStatusQuery } from './queries/get-mfa-status/get-mfa-status.query';
|
||||
export { GetMfaStatusHandler, type MfaStatusDto } from './queries/get-mfa-status/get-mfa-status.handler';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, type LoggerService, ValidationException } from '@modules/shared';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { GetMfaStatusQuery } from './get-mfa-status.query';
|
||||
|
||||
export interface MfaStatusDto {
|
||||
enabled: boolean;
|
||||
enabledAt: string | null;
|
||||
backupCodesRemaining: number;
|
||||
}
|
||||
|
||||
@QueryHandler(GetMfaStatusQuery)
|
||||
export class GetMfaStatusHandler implements IQueryHandler<GetMfaStatusQuery> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMfaStatusQuery): Promise<MfaStatusDto> {
|
||||
try {
|
||||
const user = await this.userRepo.findById(query.userId);
|
||||
if (!user) {
|
||||
throw new ValidationException('Người dùng không tồn tại');
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: user.totpEnabled,
|
||||
enabledAt: user.totpEnabledAt?.toISOString() ?? null,
|
||||
backupCodesRemaining: user.totpBackupCodes.length,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to get MFA status: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể lấy trạng thái MFA');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetMfaStatusQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
Reference in New Issue
Block a user