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) {}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { CancelUserDeletionHandler } from './application/commands/cancel-user-deletion/cancel-user-deletion.handler';
|
||||
import { DisableMfaHandler } from './application/commands/disable-mfa/disable-mfa.handler';
|
||||
import { ExportUserDataHandler } from './application/commands/export-user-data/export-user-data.handler';
|
||||
import { ForceDeleteUserHandler } from './application/commands/force-delete-user/force-delete-user.handler';
|
||||
import { LoginUserHandler } from './application/commands/login-user/login-user.handler';
|
||||
@@ -10,13 +11,21 @@ import { ProcessScheduledDeletionsHandler } from './application/commands/process
|
||||
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
|
||||
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
||||
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
||||
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
||||
import { UseBackupCodeHandler } from './application/commands/use-backup-code/use-backup-code.handler';
|
||||
import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler';
|
||||
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
|
||||
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
|
||||
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
||||
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
|
||||
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
|
||||
import { MFA_CHALLENGE_REPOSITORY } from './domain/repositories/mfa-challenge.repository';
|
||||
import { REFRESH_TOKEN_REPOSITORY } from './domain/repositories/refresh-token.repository';
|
||||
import { USER_REPOSITORY } from './domain/repositories/user.repository';
|
||||
import { PrismaMfaChallengeRepository } from './infrastructure/repositories/prisma-mfa-challenge.repository';
|
||||
import { PrismaRefreshTokenRepository } from './infrastructure/repositories/prisma-refresh-token.repository';
|
||||
import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository';
|
||||
import { MfaService } from './infrastructure/services/mfa.service';
|
||||
import { OAuthService } from './infrastructure/services/oauth.service';
|
||||
import { TokenService } from './infrastructure/services/token.service';
|
||||
import { GoogleOAuthStrategy } from './infrastructure/strategies/google-oauth.strategy';
|
||||
@@ -24,6 +33,7 @@ import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './infrastructure/strategies/local.strategy';
|
||||
import { ZaloOAuthStrategy } from './infrastructure/strategies/zalo-oauth.strategy';
|
||||
import { AuthController } from './presentation/controllers/auth.controller';
|
||||
import { MfaController } from './presentation/controllers/mfa.controller';
|
||||
import { OAuthController } from './presentation/controllers/oauth.controller';
|
||||
import { UserDataController } from './presentation/controllers/user-data.controller';
|
||||
|
||||
@@ -37,9 +47,15 @@ const CommandHandlers = [
|
||||
ForceDeleteUserHandler,
|
||||
ProcessScheduledDeletionsHandler,
|
||||
ExportUserDataHandler,
|
||||
// MFA
|
||||
SetupMfaHandler,
|
||||
VerifyMfaSetupHandler,
|
||||
VerifyMfaChallengeHandler,
|
||||
DisableMfaHandler,
|
||||
UseBackupCodeHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
||||
const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusHandler];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -58,11 +74,12 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController, OAuthController, UserDataController],
|
||||
controllers: [AuthController, MfaController, OAuthController, UserDataController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: USER_REPOSITORY, useClass: PrismaUserRepository },
|
||||
{ provide: REFRESH_TOKEN_REPOSITORY, useClass: PrismaRefreshTokenRepository },
|
||||
{ provide: MFA_CHALLENGE_REPOSITORY, useClass: PrismaMfaChallengeRepository },
|
||||
|
||||
// Strategies
|
||||
JwtStrategy,
|
||||
@@ -73,11 +90,12 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
||||
// Services
|
||||
TokenService,
|
||||
OAuthService,
|
||||
MfaService,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [TokenService, OAuthService, USER_REPOSITORY],
|
||||
exports: [TokenService, OAuthService, MfaService, USER_REPOSITORY],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface UserProps {
|
||||
kycStatus: KYCStatus;
|
||||
kycData: unknown;
|
||||
isActive: boolean;
|
||||
totpSecret: string | null;
|
||||
totpEnabled: boolean;
|
||||
totpBackupCodes: string[];
|
||||
totpEnabledAt: Date | null;
|
||||
}
|
||||
|
||||
export class UserEntity extends AggregateRoot<string> {
|
||||
@@ -29,6 +33,10 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
private _kycStatus: KYCStatus;
|
||||
private _kycData: unknown;
|
||||
private _isActive: boolean;
|
||||
private _totpSecret: string | null;
|
||||
private _totpEnabled: boolean;
|
||||
private _totpBackupCodes: string[];
|
||||
private _totpEnabledAt: Date | null;
|
||||
|
||||
constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) {
|
||||
super(id, createdAt, updatedAt);
|
||||
@@ -41,6 +49,10 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
this._kycStatus = props.kycStatus;
|
||||
this._kycData = props.kycData;
|
||||
this._isActive = props.isActive;
|
||||
this._totpSecret = props.totpSecret;
|
||||
this._totpEnabled = props.totpEnabled;
|
||||
this._totpBackupCodes = props.totpBackupCodes;
|
||||
this._totpEnabledAt = props.totpEnabledAt;
|
||||
}
|
||||
|
||||
get email(): Email | null { return this._email; }
|
||||
@@ -52,6 +64,10 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
get kycStatus(): KYCStatus { return this._kycStatus; }
|
||||
get kycData(): unknown { return this._kycData; }
|
||||
get isActive(): boolean { return this._isActive; }
|
||||
get totpSecret(): string | null { return this._totpSecret; }
|
||||
get totpEnabled(): boolean { return this._totpEnabled; }
|
||||
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
|
||||
get totpEnabledAt(): Date | null { return this._totpEnabledAt; }
|
||||
|
||||
static createNew(
|
||||
id: string,
|
||||
@@ -71,6 +87,10 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive: true,
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
});
|
||||
|
||||
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
||||
@@ -97,4 +117,25 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
this._isActive = true;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
enableTotp(secret: string, backupCodes: string[]): void {
|
||||
this._totpSecret = secret;
|
||||
this._totpEnabled = true;
|
||||
this._totpBackupCodes = backupCodes;
|
||||
this._totpEnabledAt = new Date();
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
disableTotp(): void {
|
||||
this._totpSecret = null;
|
||||
this._totpEnabled = false;
|
||||
this._totpBackupCodes = [];
|
||||
this._totpEnabledAt = null;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
consumeBackupCode(index: number): void {
|
||||
this._totpBackupCodes = this._totpBackupCodes.filter((_, i) => i !== index);
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,8 @@ export {
|
||||
type IRefreshTokenRepository,
|
||||
type RefreshTokenRecord,
|
||||
} from './refresh-token.repository';
|
||||
export {
|
||||
MFA_CHALLENGE_REPOSITORY,
|
||||
type IMfaChallengeRepository,
|
||||
type MfaChallengeRecord,
|
||||
} from './mfa-challenge.repository';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const MFA_CHALLENGE_REPOSITORY = Symbol('MFA_CHALLENGE_REPOSITORY');
|
||||
|
||||
export interface MfaChallengeRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
attemptCount: number;
|
||||
maxAttempts: number;
|
||||
isVerified: boolean;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface IMfaChallengeRepository {
|
||||
create(record: Omit<MfaChallengeRecord, 'createdAt'>): Promise<MfaChallengeRecord>;
|
||||
findById(id: string): Promise<MfaChallengeRecord | null>;
|
||||
incrementAttempts(id: string): Promise<void>;
|
||||
markVerified(id: string): Promise<void>;
|
||||
deleteExpired(): Promise<number>;
|
||||
deleteByUserId(userId: string): Promise<number>;
|
||||
}
|
||||
@@ -8,4 +8,8 @@ export interface IUserRepository {
|
||||
findByEmail(email: string): Promise<UserEntity | null>;
|
||||
save(user: UserEntity): Promise<void>;
|
||||
update(user: UserEntity): Promise<void>;
|
||||
updateMfaSecret(userId: string, secret: string | null): Promise<void>;
|
||||
updateMfaEnabled(userId: string, enabled: boolean, secret: string, backupCodes: string[]): Promise<void>;
|
||||
updateMfaDisabled(userId: string): Promise<void>;
|
||||
updateBackupCodes(userId: string, backupCodes: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -44,9 +44,13 @@ describe('PrismaUserRepository', () => {
|
||||
let mockPrisma: {
|
||||
user: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
fieldEncryption: {
|
||||
computeHash: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const mockPrismaUser = {
|
||||
@@ -68,9 +72,13 @@ describe('PrismaUserRepository', () => {
|
||||
mockPrisma = {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
fieldEncryption: {
|
||||
computeHash: vi.fn((value: string) => `hash_${value.toLowerCase().trim()}`),
|
||||
},
|
||||
};
|
||||
repository = new PrismaUserRepository(mockPrisma as any);
|
||||
});
|
||||
@@ -96,7 +104,10 @@ describe('PrismaUserRepository', () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||
const result = await repository.findByPhone('+84912345678');
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { phone: '+84912345678' } });
|
||||
// With encryption enabled, should query by phoneHash
|
||||
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { phoneHash: 'hash_+84912345678' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns domain entity when user is found', async () => {
|
||||
@@ -104,6 +115,16 @@ describe('PrismaUserRepository', () => {
|
||||
const result = await repository.findByPhone('+84912345678');
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to plaintext search when encryption disabled', async () => {
|
||||
mockPrisma.fieldEncryption.computeHash.mockReturnValue(null);
|
||||
mockPrisma.user.findFirst.mockResolvedValue(null);
|
||||
const result = await repository.findByPhone('+84912345678');
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: { phone: '+84912345678' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByEmail', () => {
|
||||
@@ -111,7 +132,20 @@ describe('PrismaUserRepository', () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||
const result = await repository.findByEmail('test@example.com');
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { email: 'test@example.com' } });
|
||||
// With encryption enabled, should query by emailHash
|
||||
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { emailHash: 'hash_test@example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to plaintext search when encryption disabled', async () => {
|
||||
mockPrisma.fieldEncryption.computeHash.mockReturnValue(null);
|
||||
mockPrisma.user.findFirst.mockResolvedValue(null);
|
||||
const result = await repository.findByEmail('test@example.com');
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: { email: 'test@example.com' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { PrismaUserRepository } from './prisma-user.repository';
|
||||
export { PrismaRefreshTokenRepository } from './prisma-refresh-token.repository';
|
||||
export { PrismaMfaChallengeRepository } from './prisma-mfa-challenge.repository';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type IMfaChallengeRepository,
|
||||
type MfaChallengeRecord,
|
||||
} from '../../domain/repositories/mfa-challenge.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaMfaChallengeRepository implements IMfaChallengeRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(
|
||||
record: Omit<MfaChallengeRecord, 'createdAt'>,
|
||||
): Promise<MfaChallengeRecord> {
|
||||
return this.prisma.mfaChallenge.create({ data: record });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<MfaChallengeRecord | null> {
|
||||
return this.prisma.mfaChallenge.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async incrementAttempts(id: string): Promise<void> {
|
||||
await this.prisma.mfaChallenge.update({
|
||||
where: { id },
|
||||
data: { attemptCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async markVerified(id: string): Promise<void> {
|
||||
await this.prisma.mfaChallenge.update({
|
||||
where: { id },
|
||||
data: { isVerified: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<number> {
|
||||
const result = await this.prisma.mfaChallenge.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.prisma.mfaChallenge.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,24 @@ export class PrismaUserRepository implements IUserRepository {
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<UserEntity | null> {
|
||||
const user = await this.prisma.user.findUnique({ where: { phone } });
|
||||
const hash = this.prisma.fieldEncryption.computeHash(phone);
|
||||
if (hash) {
|
||||
const user = await this.prisma.user.findUnique({ where: { phoneHash: hash } });
|
||||
return user ? this.toDomain(user) : null;
|
||||
}
|
||||
// Fallback: encryption not configured — query plaintext
|
||||
const user = await this.prisma.user.findFirst({ where: { phone } });
|
||||
return user ? this.toDomain(user) : null;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<UserEntity | null> {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
const hash = this.prisma.fieldEncryption.computeHash(email);
|
||||
if (hash) {
|
||||
const user = await this.prisma.user.findUnique({ where: { emailHash: hash } });
|
||||
return user ? this.toDomain(user) : null;
|
||||
}
|
||||
// Fallback: encryption not configured — query plaintext
|
||||
const user = await this.prisma.user.findFirst({ where: { email } });
|
||||
return user ? this.toDomain(user) : null;
|
||||
}
|
||||
|
||||
@@ -39,6 +51,10 @@ export class PrismaUserRepository implements IUserRepository {
|
||||
kycStatus: entity.kycStatus,
|
||||
kycData: entity.kycData as Prisma.InputJsonValue,
|
||||
isActive: entity.isActive,
|
||||
totpSecret: entity.totpSecret,
|
||||
totpEnabled: entity.totpEnabled,
|
||||
totpBackupCodes: entity.totpBackupCodes,
|
||||
totpEnabledAt: entity.totpEnabledAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -56,10 +72,57 @@ export class PrismaUserRepository implements IUserRepository {
|
||||
kycStatus: entity.kycStatus,
|
||||
kycData: entity.kycData as Prisma.InputJsonValue,
|
||||
isActive: entity.isActive,
|
||||
totpSecret: entity.totpSecret,
|
||||
totpEnabled: entity.totpEnabled,
|
||||
totpBackupCodes: entity.totpBackupCodes,
|
||||
totpEnabledAt: entity.totpEnabledAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateMfaSecret(userId: string, secret: string | null): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { totpSecret: secret },
|
||||
});
|
||||
}
|
||||
|
||||
async updateMfaEnabled(
|
||||
userId: string,
|
||||
enabled: boolean,
|
||||
secret: string,
|
||||
backupCodes: string[],
|
||||
): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
totpEnabled: enabled,
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: backupCodes,
|
||||
totpEnabledAt: enabled ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateMfaDisabled(userId: string): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateBackupCodes(userId: string, backupCodes: string[]): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { totpBackupCodes: backupCodes },
|
||||
});
|
||||
}
|
||||
|
||||
private toDomain(raw: PrismaUser): UserEntity {
|
||||
const phone = Phone.create(raw.phone).unwrap();
|
||||
const email = raw.email ? Email.create(raw.email).unwrap() : null;
|
||||
@@ -77,6 +140,10 @@ export class PrismaUserRepository implements IUserRepository {
|
||||
kycStatus: raw.kycStatus,
|
||||
kycData: raw.kycData,
|
||||
isActive: raw.isActive,
|
||||
totpSecret: raw.totpSecret,
|
||||
totpEnabled: raw.totpEnabled,
|
||||
totpBackupCodes: raw.totpBackupCodes,
|
||||
totpEnabledAt: raw.totpEnabledAt,
|
||||
};
|
||||
|
||||
return new UserEntity(raw.id, props, raw.createdAt, raw.updatedAt);
|
||||
|
||||
@@ -4,3 +4,8 @@ export {
|
||||
type TokenPair,
|
||||
type RotateResult,
|
||||
} from './token.service';
|
||||
export {
|
||||
MfaService,
|
||||
type MfaSetupResult,
|
||||
type BackupCodeResult,
|
||||
} from './mfa.service';
|
||||
|
||||
118
apps/api/src/modules/auth/infrastructure/services/mfa.service.ts
Normal file
118
apps/api/src/modules/auth/infrastructure/services/mfa.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createHmac, randomBytes } from 'crypto';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { generateSecret, generateURI, verify } from 'otplib';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
const TOTP_ISSUER = 'GoodGo Platform';
|
||||
const BACKUP_CODE_COUNT = 10;
|
||||
const BACKUP_CODE_LENGTH = 8;
|
||||
const TOTP_EPOCH_TOLERANCE = 30; // 1-step clock skew (30 seconds)
|
||||
|
||||
export interface MfaSetupResult {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
qrCodeDataUrl: string;
|
||||
}
|
||||
|
||||
export interface BackupCodeResult {
|
||||
codes: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MfaService {
|
||||
private readonly logger = new Logger(MfaService.name);
|
||||
|
||||
/**
|
||||
* Generate a new TOTP secret and QR code for setup.
|
||||
*/
|
||||
async generateSetup(userIdentifier: string): Promise<MfaSetupResult> {
|
||||
const secret = generateSecret();
|
||||
const otpauthUrl = generateURI({
|
||||
issuer: TOTP_ISSUER,
|
||||
label: userIdentifier,
|
||||
secret,
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
|
||||
|
||||
return { secret, otpauthUrl, qrCodeDataUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TOTP code against a secret.
|
||||
* Returns true if valid within the configured window.
|
||||
*/
|
||||
async verifyTotp(token: string, secret: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await verify({
|
||||
secret,
|
||||
token,
|
||||
epochTolerance: TOTP_EPOCH_TOLERANCE,
|
||||
});
|
||||
return result.valid;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`TOTP verification error: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup codes.
|
||||
* Returns plaintext codes (to show to user) and hashed versions (to store).
|
||||
*/
|
||||
generateBackupCodes(): { plainCodes: string[]; hashedCodes: string[] } {
|
||||
const plainCodes: string[] = [];
|
||||
const hashedCodes: string[] = [];
|
||||
|
||||
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
||||
const code = this.generateReadableCode(BACKUP_CODE_LENGTH);
|
||||
plainCodes.push(code);
|
||||
hashedCodes.push(this.hashBackupCode(code));
|
||||
}
|
||||
|
||||
return { plainCodes, hashedCodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a backup code against a list of hashed codes.
|
||||
* Returns the index of the matching code, or -1 if not found.
|
||||
*/
|
||||
verifyBackupCode(code: string, hashedCodes: string[]): number {
|
||||
const normalizedCode = code.replace(/[\s-]/g, '').toUpperCase();
|
||||
const hashedInput = this.hashBackupCode(normalizedCode);
|
||||
|
||||
for (let i = 0; i < hashedCodes.length; i++) {
|
||||
if (hashedCodes[i] === hashedInput) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable alphanumeric code (excluding ambiguous characters).
|
||||
*/
|
||||
private generateReadableCode(length: number): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, I, 1
|
||||
const bytes = randomBytes(length);
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[bytes[i]! % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a backup code using HMAC-SHA256.
|
||||
* Uses a fixed key derived from the app secret for consistent hashing.
|
||||
*/
|
||||
private hashBackupCode(code: string): string {
|
||||
const secret = process.env['MFA_BACKUP_CODE_SECRET'] || process.env['JWT_SECRET'] || 'goodgo-mfa-backup-default';
|
||||
return createHmac('sha256', secret).update(code).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,10 @@ export class OAuthService {
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive: true,
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
});
|
||||
|
||||
await this.userRepo.save(user);
|
||||
|
||||
@@ -4,6 +4,13 @@ import { Strategy } from 'passport-local';
|
||||
import { DomainException, normalizeVietnamPhone, UnauthorizedException } from '@modules/shared';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
|
||||
|
||||
export interface LocalStrategyResult {
|
||||
id: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
isMfaRequired: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
private readonly logger = new Logger(LocalStrategy.name);
|
||||
@@ -15,7 +22,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
super({ usernameField: 'phone', passwordField: 'password' });
|
||||
}
|
||||
|
||||
async validate(phone: string, password: string): Promise<{ id: string; phone: string; role: string }> {
|
||||
async validate(phone: string, password: string): Promise<LocalStrategyResult> {
|
||||
try {
|
||||
if (!phone || !password) {
|
||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||
@@ -40,7 +47,12 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||
}
|
||||
|
||||
return { id: user.id, phone: user.phone.value, role: user.role };
|
||||
return {
|
||||
id: user.id,
|
||||
phone: user.phone.value,
|
||||
role: user.role,
|
||||
isMfaRequired: user.totpEnabled,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Throttle } from '@nestjs/throttler';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { EndpointRateLimit, EndpointRateLimitGuard, UnauthorizedException } from '@modules/shared';
|
||||
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 { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command';
|
||||
@@ -22,6 +23,7 @@ import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-us
|
||||
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 { LoginDto } from '../dto/login.dto';
|
||||
@@ -107,20 +109,29 @@ export class AuthController {
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with phone and password' })
|
||||
@ApiBody({ type: LoginDto })
|
||||
@ApiResponse({ status: 201, description: 'Login successful, auth cookies set' })
|
||||
@ApiResponse({ status: 201, description: 'Login successful, auth cookies set (or MFA challenge returned)' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
async login(
|
||||
@CurrentUser() user: { id: string; phone: string; role: string },
|
||||
@CurrentUser() user: LocalStrategyResult,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ message: string; accessToken: string; refreshToken: string }> {
|
||||
const tokens: TokenPair = await this.commandBus.execute(
|
||||
new LoginUserCommand(user.id, user.phone, user.role),
|
||||
): 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),
|
||||
);
|
||||
setAuthCookies(res, tokens);
|
||||
|
||||
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: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
accessToken: result.tokens!.accessToken,
|
||||
refreshToken: result.tokens!.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { AuthController } from './auth.controller';
|
||||
export { MfaController } from './mfa.controller';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { RegisterDto } from './register.dto';
|
||||
export { LoginDto } from './login.dto';
|
||||
export { RefreshTokenDto } from './refresh-token.dto';
|
||||
export { VerifyKycDto } from './verify-kyc.dto';
|
||||
export { VerifyMfaSetupDto, VerifyMfaChallengeDto, UseBackupCodeDto, DisableMfaDto } from './mfa.dto';
|
||||
|
||||
38
apps/api/src/modules/auth/presentation/dto/mfa.dto.ts
Normal file
38
apps/api/src/modules/auth/presentation/dto/mfa.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
export class VerifyMfaSetupDto {
|
||||
@ApiProperty({ description: 'Mã TOTP 6 chữ số từ ứng dụng authenticator', example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6, { message: 'Mã TOTP phải có 6 chữ số' })
|
||||
totpCode!: string;
|
||||
}
|
||||
|
||||
export class VerifyMfaChallengeDto {
|
||||
@ApiProperty({ description: 'ID phiên xác thực MFA' })
|
||||
@IsString()
|
||||
challengeId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Mã TOTP 6 chữ số', example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6, { message: 'Mã TOTP phải có 6 chữ số' })
|
||||
totpCode!: string;
|
||||
}
|
||||
|
||||
export class UseBackupCodeDto {
|
||||
@ApiProperty({ description: 'ID phiên xác thực MFA' })
|
||||
@IsString()
|
||||
challengeId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Mã backup 8 ký tự', example: 'ABCD1234' })
|
||||
@IsString()
|
||||
@Length(8, 8, { message: 'Mã backup phải có 8 ký tự' })
|
||||
backupCode!: string;
|
||||
}
|
||||
|
||||
export class DisableMfaDto {
|
||||
@ApiProperty({ description: 'Mã TOTP hiện tại để xác nhận tắt MFA', example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6, { message: 'Mã TOTP phải có 6 chữ số' })
|
||||
totpCode!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user