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

@@ -42,6 +42,7 @@
"helmet": "^8.1.0",
"ioredis": "^5.4.0",
"nodemailer": "^8.0.5",
"otplib": "^13.4.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
@@ -50,6 +51,7 @@
"pino": "^10.3.1",
"pino-pretty": "^13.0.0",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"sanitize-html": "^2.17.2",
@@ -70,6 +72,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.16.1",
"@types/supertest": "^7.2.0",
"prisma": "^7.7.0",

View File

@@ -22,11 +22,17 @@ export async function getUsers(
if (role) where.role = role as UserRole;
if (isActive !== undefined) where.isActive = isActive;
if (search) {
where.OR = [
// With encrypted email/phone fields, LIKE search only works on fullName.
// For exact email/phone lookups, use deterministic hash columns.
const hashLookup = prisma.fieldEncryption.computeHash(search);
const conditions: Prisma.UserWhereInput[] = [
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search } },
];
if (hashLookup) {
conditions.push({ emailHash: hashLookup });
conditions.push({ phoneHash: hashLookup });
}
where.OR = conditions;
}
const [users, total] = await Promise.all([

View File

@@ -0,0 +1,3 @@
export { RecalculateQualityScoreCommand } from './commands/recalculate-quality-score/recalculate-quality-score.command';
export { GetAgentDashboardQuery } from './queries/get-agent-dashboard/get-agent-dashboard.query';
export { GetAgentPublicProfileQuery } from './queries/get-agent-public-profile/get-agent-public-profile.query';

View File

@@ -0,0 +1,61 @@
import { AggregateRoot } from '@modules/shared';
import { QualityScoreUpdatedEvent } from '../events/quality-score-updated.event';
import { type QualityScore } from '../value-objects/quality-score.vo';
export interface AgentProps {
userId: string;
licenseNumber: string | null;
agency: string | null;
qualityScore: QualityScore;
totalDeals: number;
responseTimeAvg: number | null;
bio: string | null;
serviceAreas: string[];
isVerified: boolean;
}
export class AgentEntity extends AggregateRoot<string> {
private _userId: string;
private _licenseNumber: string | null;
private _agency: string | null;
private _qualityScore: QualityScore;
private _totalDeals: number;
private _responseTimeAvg: number | null;
private _bio: string | null;
private _serviceAreas: string[];
private _isVerified: boolean;
constructor(id: string, props: AgentProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt);
if (updatedAt) this.updatedAt = updatedAt;
this._userId = props.userId;
this._licenseNumber = props.licenseNumber;
this._agency = props.agency;
this._qualityScore = props.qualityScore;
this._totalDeals = props.totalDeals;
this._responseTimeAvg = props.responseTimeAvg;
this._bio = props.bio;
this._serviceAreas = props.serviceAreas;
this._isVerified = props.isVerified;
}
get userId(): string { return this._userId; }
get licenseNumber(): string | null { return this._licenseNumber; }
get agency(): string | null { return this._agency; }
get qualityScore(): QualityScore { return this._qualityScore; }
get totalDeals(): number { return this._totalDeals; }
get responseTimeAvg(): number | null { return this._responseTimeAvg; }
get bio(): string | null { return this._bio; }
get serviceAreas(): string[] { return this._serviceAreas; }
get isVerified(): boolean { return this._isVerified; }
updateQualityScore(newScore: QualityScore): void {
const oldScore = this._qualityScore.value;
this._qualityScore = newScore;
this.updatedAt = new Date();
this.addDomainEvent(
new QualityScoreUpdatedEvent(this.id, oldScore, newScore.value),
);
}
}

View File

@@ -0,0 +1 @@
export { AgentEntity, type AgentProps } from './agent.entity';

View File

@@ -0,0 +1 @@
export { QualityScoreUpdatedEvent } from './quality-score-updated.event';

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class QualityScoreUpdatedEvent implements DomainEvent {
readonly eventName = 'agent.quality_score_updated';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly oldScore: number,
public readonly newScore: number,
) {}
}

View File

@@ -0,0 +1,5 @@
export * from './entities';
export * from './value-objects';
export * from './events';
export * from './repositories';
export { QualityScoreCalculator } from './services/quality-score.service';

View File

@@ -0,0 +1,8 @@
export {
AGENT_REPOSITORY,
type IAgentRepository,
type AgentDashboardData,
type AgentPublicProfileData,
type AgentPublicListingItem,
type QualityScoreInputData,
} from './agent.repository';

View File

@@ -0,0 +1 @@
export { QualityScore } from './quality-score.vo';

View File

@@ -0,0 +1,22 @@
import { Result, ValueObject } from '@modules/shared';
interface QualityScoreProps {
value: number;
}
export class QualityScore extends ValueObject<QualityScoreProps> {
get value(): number { return this.props.value; }
static create(value: number): Result<QualityScore, string> {
if (value < 0 || value > 100) {
return Result.err('Điểm chất lượng phải từ 0 đến 100');
}
const rounded = Math.round(value * 10) / 10; // 1 decimal place
return Result.ok(new QualityScore({ value: rounded }));
}
/** Create from a raw database value (trusted, no validation). */
static fromPersistence(value: number): QualityScore {
return new QualityScore({ value });
}
}

View File

@@ -0,0 +1,373 @@
import { AgentEntity } from '../../domain/entities/agent.entity';
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
import { PrismaAgentRepository } from '../repositories/prisma-agent.repository';
describe('PrismaAgentRepository', () => {
let repository: PrismaAgentRepository;
let mockPrisma: {
agent: {
findUnique: ReturnType<typeof vi.fn>;
findUniqueOrThrow: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
lead: {
groupBy: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
inquiry: {
count: ReturnType<typeof vi.fn>;
};
listing: {
count: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
review: {
aggregate: ReturnType<typeof vi.fn>;
};
};
const agentRow = {
id: 'agent-1',
userId: 'user-1',
licenseNumber: 'BDS-001',
agency: 'Công ty BĐS',
qualityScore: 85,
totalDeals: 12,
responseTimeAvg: 600,
bio: 'Chuyên viên BĐS',
serviceAreas: ['Quận 7'],
isVerified: true,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-06-01'),
};
beforeEach(() => {
mockPrisma = {
agent: {
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
lead: {
groupBy: vi.fn(),
count: vi.fn(),
},
inquiry: {
count: vi.fn(),
},
listing: {
count: vi.fn(),
findMany: vi.fn(),
},
review: {
aggregate: vi.fn(),
},
};
repository = new PrismaAgentRepository(mockPrisma as any);
});
describe('findByUserId', () => {
it('returns AgentEntity when found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(agentRow);
const result = await repository.findByUserId('user-1');
expect(result).toBeInstanceOf(AgentEntity);
expect(result!.id).toBe('agent-1');
expect(result!.userId).toBe('user-1');
expect(result!.qualityScore.value).toBe(85);
});
it('returns null when not found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const result = await repository.findByUserId('nonexistent');
expect(result).toBeNull();
});
});
describe('findById', () => {
it('returns AgentEntity when found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(agentRow);
const result = await repository.findById('agent-1');
expect(result).toBeInstanceOf(AgentEntity);
expect(result!.id).toBe('agent-1');
expect(result!.qualityScore.value).toBe(85);
});
it('returns null when not found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const result = await repository.findById('nonexistent');
expect(result).toBeNull();
});
});
describe('save', () => {
it('updates the quality score for the agent', async () => {
mockPrisma.agent.update.mockResolvedValue(undefined);
const agent = new AgentEntity('agent-1', {
userId: 'user-1',
licenseNumber: null,
agency: null,
qualityScore: QualityScore.fromPersistence(92),
totalDeals: 0,
responseTimeAvg: null,
bio: null,
serviceAreas: [],
isVerified: false,
});
await repository.save(agent);
expect(mockPrisma.agent.update).toHaveBeenCalledWith({
where: { id: 'agent-1' },
data: { qualityScore: 92 },
});
});
it('handles score of 0', async () => {
mockPrisma.agent.update.mockResolvedValue(undefined);
const agent = new AgentEntity('agent-1', {
userId: 'user-1',
licenseNumber: null,
agency: null,
qualityScore: QualityScore.fromPersistence(0),
totalDeals: 0,
responseTimeAvg: null,
bio: null,
serviceAreas: [],
isVerified: false,
});
await repository.save(agent);
expect(mockPrisma.agent.update).toHaveBeenCalledWith({
where: { id: 'agent-1' },
data: { qualityScore: 0 },
});
});
});
describe('getQualityScoreInputs', () => {
it('returns quality score input data', async () => {
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 4.5 },
_count: { rating: 10 },
});
mockPrisma.lead.count
.mockResolvedValueOnce(20) // totalLeads
.mockResolvedValueOnce(5); // convertedLeads
mockPrisma.listing.count
.mockResolvedValueOnce(10) // totalListings
.mockResolvedValueOnce(7); // activeListings
mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: 900 });
const result = await repository.getQualityScoreInputs('agent-1');
expect(result.avgRating).toBe(4.5);
expect(result.totalReviews).toBe(10);
expect(result.responseTimeAvg).toBe(900);
expect(result.conversionRate).toBe(0.25);
expect(result.activeListingRatio).toBe(0.7);
});
it('handles zero counts gracefully', async () => {
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: null },
_count: { rating: 0 },
});
mockPrisma.lead.count.mockResolvedValue(0);
mockPrisma.listing.count.mockResolvedValue(0);
mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: null });
const result = await repository.getQualityScoreInputs('agent-1');
expect(result.avgRating).toBe(0);
expect(result.totalReviews).toBe(0);
expect(result.responseTimeAvg).toBeNull();
expect(result.conversionRate).toBe(0);
expect(result.activeListingRatio).toBe(0);
});
});
describe('getDashboard', () => {
it('returns full dashboard data', async () => {
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({
id: 'agent-1',
qualityScore: 85,
totalDeals: 12,
responseTimeAvg: 600,
isVerified: true,
});
mockPrisma.lead.groupBy.mockResolvedValue([
{ status: 'NEW', _count: { id: 5 } },
{ status: 'CONTACTED', _count: { id: 10 } },
{ status: 'CONVERTED', _count: { id: 3 } },
]);
mockPrisma.inquiry.count
.mockResolvedValueOnce(45) // total
.mockResolvedValueOnce(3); // unread
mockPrisma.listing.count
.mockResolvedValueOnce(15) // total
.mockResolvedValueOnce(10); // active
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 4.5 },
_count: { rating: 20 },
});
const result = await repository.getDashboard('agent-1');
expect(result.agentId).toBe('agent-1');
expect(result.qualityScore).toBe(85);
expect(result.totalDeals).toBe(12);
expect(result.responseTimeAvg).toBe(600);
expect(result.isVerified).toBe(true);
expect(result.totalLeads).toBe(18);
expect(result.leadsByStatus).toEqual({ NEW: 5, CONTACTED: 10, CONVERTED: 3 });
expect(result.conversionRate).toBe(0.167);
expect(result.totalInquiries).toBe(45);
expect(result.unreadInquiries).toBe(3);
expect(result.totalListings).toBe(15);
expect(result.activeListings).toBe(10);
expect(result.avgReviewRating).toBe(4.5);
expect(result.totalReviews).toBe(20);
});
it('handles agent with zero leads', async () => {
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({
id: 'agent-1',
qualityScore: 0,
totalDeals: 0,
responseTimeAvg: null,
isVerified: false,
});
mockPrisma.lead.groupBy.mockResolvedValue([]);
mockPrisma.inquiry.count.mockResolvedValue(0);
mockPrisma.listing.count.mockResolvedValue(0);
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: null },
_count: { rating: 0 },
});
const result = await repository.getDashboard('agent-1');
expect(result.totalLeads).toBe(0);
expect(result.conversionRate).toBe(0);
expect(result.leadsByStatus).toEqual({});
expect(result.avgReviewRating).toBe(0);
expect(result.totalReviews).toBe(0);
});
});
describe('getPublicProfile', () => {
it('returns null when agent not found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const result = await repository.getPublicProfile('nonexistent');
expect(result).toBeNull();
});
it('returns full public profile', async () => {
const now = new Date();
mockPrisma.agent.findUnique.mockResolvedValue({
id: 'agent-1',
agency: 'Công ty BĐS ABC',
licenseNumber: 'BDS-001',
bio: 'Chuyên viên BĐS',
qualityScore: 85,
totalDeals: 50,
isVerified: true,
serviceAreas: ['Quận 7', 'Quận 2'],
createdAt: now,
user: {
fullName: 'Nguyễn Văn A',
avatarUrl: 'https://example.com/avatar.jpg',
phone: '0901234567',
email: 'agent@example.com',
createdAt: now,
},
});
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 4.5 },
_count: { rating: 20 },
});
const result = await repository.getPublicProfile('agent-1');
expect(result).not.toBeNull();
expect(result!.id).toBe('agent-1');
expect(result!.fullName).toBe('Nguyễn Văn A');
expect(result!.agency).toBe('Công ty BĐS ABC');
expect(result!.qualityScore).toBe(85);
expect(result!.serviceAreas).toEqual(['Quận 7', 'Quận 2']);
expect(result!.avgReviewRating).toBe(4.5);
expect(result!.totalReviews).toBe(20);
expect(result!.activeListings).toEqual([]);
});
it('returns profile with active listings including property data', async () => {
const now = new Date();
mockPrisma.agent.findUnique.mockResolvedValue({
id: 'agent-1',
agency: null,
licenseNumber: null,
bio: null,
qualityScore: 70,
totalDeals: 5,
isVerified: true,
serviceAreas: [],
createdAt: now,
user: {
fullName: 'Lê Văn C',
avatarUrl: null,
phone: '0903456789',
email: null,
createdAt: now,
},
});
mockPrisma.listing.findMany.mockResolvedValue([
{
id: 'listing-1',
transactionType: 'SALE',
priceVND: BigInt('5000000000'),
status: 'ACTIVE',
property: {
id: 'prop-1',
title: 'Căn hộ cao cấp',
propertyType: 'APARTMENT',
address: '123 Nguyễn Hữu Thọ',
district: 'Quận 7',
city: 'TP.HCM',
areaM2: 75,
bedrooms: 2,
bathrooms: 2,
media: [{ url: 'https://example.com/image.jpg' }],
},
},
]);
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 3.8 },
_count: { rating: 5 },
});
const result = await repository.getPublicProfile('agent-1');
expect(result!.activeListings).toHaveLength(1);
const listing = result!.activeListings[0]!;
expect(listing.id).toBe('listing-1');
expect(listing.transactionType).toBe('SALE');
expect(listing.priceVND).toBe('5000000000');
expect(listing.property.title).toBe('Căn hộ cao cấp');
expect(listing.property.imageUrl).toBe('https://example.com/image.jpg');
});
});
});

View File

@@ -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);

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');
}
}
}

View File

@@ -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';

View File

@@ -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');
}
}
}

View File

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

View File

@@ -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 {}

View File

@@ -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();
}
}

View File

@@ -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';

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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' },
});
});
});

View File

@@ -1,2 +1,3 @@
export { PrismaUserRepository } from './prisma-user.repository';
export { PrismaRefreshTokenRepository } from './prisma-refresh-token.repository';
export { PrismaMfaChallengeRepository } from './prisma-mfa-challenge.repository';

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -4,3 +4,8 @@ export {
type TokenPair,
type RotateResult,
} from './token.service';
export {
MfaService,
type MfaSetupResult,
type BackupCodeResult,
} from './mfa.service';

View 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');
}
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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,
};
}

View File

@@ -1 +1,2 @@
export { AuthController } from './auth.controller';
export { MfaController } from './mfa.controller';

View File

@@ -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));
}
}

View File

@@ -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';

View 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;
}

View File

@@ -0,0 +1,4 @@
export { CreateInquiryCommand } from './commands/create-inquiry/create-inquiry.command';
export { MarkInquiryReadCommand } from './commands/mark-inquiry-read/mark-inquiry-read.command';
export { GetInquiriesByAgentQuery } from './queries/get-inquiries-by-agent/get-inquiries-by-agent.query';
export { GetInquiriesByListingQuery } from './queries/get-inquiries-by-listing/get-inquiries-by-listing.query';

View File

@@ -0,0 +1 @@
export { InquiryEntity, type InquiryProps } from './inquiry.entity';

View File

@@ -0,0 +1,2 @@
export { InquiryCreatedEvent } from './inquiry-created.event';
export { InquiryReadEvent } from './inquiry-read.event';

View File

@@ -0,0 +1,3 @@
export * from './entities';
export * from './events';
export * from './repositories';

View File

@@ -0,0 +1,6 @@
export {
INQUIRY_REPOSITORY,
type IInquiryRepository,
type PaginatedResult,
} from './inquiry.repository';
export { type InquiryReadDto } from './inquiry-read.dto';

View File

@@ -0,0 +1,5 @@
export { CreateLeadCommand } from './commands/create-lead/create-lead.command';
export { UpdateLeadStatusCommand } from './commands/update-lead-status/update-lead-status.command';
export { DeleteLeadCommand } from './commands/delete-lead/delete-lead.command';
export { GetLeadsByAgentQuery } from './queries/get-leads-by-agent/get-leads-by-agent.query';
export { GetLeadStatsQuery } from './queries/get-lead-stats/get-lead-stats.query';

View File

@@ -0,0 +1 @@
export { LeadEntity, type LeadProps, type LeadStatus } from './lead.entity';

View File

@@ -0,0 +1,2 @@
export { LeadCreatedEvent } from './lead-created.event';
export { LeadStatusChangedEvent } from './lead-status-changed.event';

View File

@@ -0,0 +1,4 @@
export * from './entities';
export * from './value-objects';
export * from './events';
export * from './repositories';

View File

@@ -0,0 +1,7 @@
export {
LEAD_REPOSITORY,
type ILeadRepository,
type PaginatedResult,
type LeadStatsData,
} from './lead.repository';
export { type LeadReadDto } from './lead-read.dto';

View File

@@ -0,0 +1 @@
export { LeadScore } from './lead-score.vo';

View File

@@ -0,0 +1,157 @@
import crypto from 'node:crypto';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createEncryptionExtension } from '../encryption-middleware';
import { isEncrypted } from '../field-encryption';
import { FieldEncryptionService } from '../field-encryption.service';
const TEST_KEY = crypto.randomBytes(32).toString('hex');
const mockLogger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
verbose: vi.fn(),
child: vi.fn(),
} as any;
describe('encryption-middleware', () => {
let service: FieldEncryptionService;
beforeEach(() => {
process.env['FIELD_ENCRYPTION_KEY'] = TEST_KEY;
process.env['FIELD_ENCRYPTION_KEY_VERSION'] = '1';
service = new FieldEncryptionService(mockLogger);
vi.clearAllMocks();
});
afterEach(() => {
delete process.env['FIELD_ENCRYPTION_KEY'];
delete process.env['FIELD_ENCRYPTION_KEY_VERSION'];
});
describe('createEncryptionExtension', () => {
it('returns a Prisma extension object', () => {
const ext = createEncryptionExtension(service);
expect(ext).toBeDefined();
});
it('returns a no-op extension when encryption is disabled', () => {
delete process.env['FIELD_ENCRYPTION_KEY'];
const disabledService = new FieldEncryptionService(mockLogger);
const ext = createEncryptionExtension(disabledService);
expect(ext).toBeDefined();
});
});
describe('FieldEncryptionService integration with extension', () => {
// Since we can't easily invoke the Prisma $extends handler in isolation
// (it requires a real PrismaClient), we test the encrypt/decrypt behavior
// that the extension uses via the service.
it('encrypt + decrypt round-trip for User.email', () => {
const original = 'user@example.com';
const encrypted = service.encrypt(original) as string;
expect(isEncrypted(encrypted)).toBe(true);
expect(service.decrypt(encrypted)).toBe(original);
});
it('encrypt + decrypt round-trip for User.kycData', () => {
const original = { idNumber: '012345678901', name: 'Nguyen Van A' };
const encrypted = service.encrypt(original) as string;
expect(isEncrypted(encrypted)).toBe(true);
expect(service.decrypt(encrypted)).toEqual(original);
});
it('encrypt + decrypt round-trip for Payment.callbackData', () => {
const original = { vnp_ResponseCode: '00', vnp_Amount: 1000000 };
const encrypted = service.encrypt(original) as string;
expect(isEncrypted(encrypted)).toBe(true);
expect(service.decrypt(encrypted)).toEqual(original);
});
it('encrypt + decrypt round-trip for simple strings (phone, providerTxId)', () => {
const phone = '+84912345678';
const txId = 'txn_12345';
expect(service.decrypt(service.encrypt(phone) as string)).toBe(phone);
expect(service.decrypt(service.encrypt(txId) as string)).toBe(txId);
});
it('computes deterministic hashes for searchable fields', () => {
const hash1 = service.computeHash('user@example.com');
const hash2 = service.computeHash('user@example.com');
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^[0-9a-f]{64}$/);
});
it('hash is case-insensitive', () => {
expect(service.computeHash('User@Example.COM')).toBe(
service.computeHash('user@example.com'),
);
});
it('does not double-encrypt already encrypted values', () => {
const encrypted = service.encrypt('test') as string;
expect(service.isAlreadyEncrypted(encrypted)).toBe(true);
// If the middleware encounters this, it should skip
});
it('decrypts plaintext (non-encrypted) values unchanged', () => {
expect(service.decrypt('plain text')).toBe('plain text');
expect(service.decrypt(null)).toBeNull();
expect(service.decrypt(42)).toBe(42);
});
});
describe('PII field map coverage', () => {
it('covers User model with email, phone, kycData', () => {
const userConfig = service.getModelConfig('User');
expect(userConfig).toBeDefined();
const fields = userConfig!.fields.map((f) => f.field);
expect(fields).toContain('email');
expect(fields).toContain('phone');
expect(fields).toContain('kycData');
});
it('covers Agent model with licenseNumber', () => {
const agentConfig = service.getModelConfig('Agent');
expect(agentConfig).toBeDefined();
expect(agentConfig!.fields.map((f) => f.field)).toContain('licenseNumber');
});
it('covers Payment model with providerTxId, callbackData', () => {
const paymentConfig = service.getModelConfig('Payment');
expect(paymentConfig).toBeDefined();
const fields = paymentConfig!.fields.map((f) => f.field);
expect(fields).toContain('providerTxId');
expect(fields).toContain('callbackData');
});
it('covers Lead model with phone, email', () => {
const leadConfig = service.getModelConfig('Lead');
expect(leadConfig).toBeDefined();
const fields = leadConfig!.fields.map((f) => f.field);
expect(fields).toContain('phone');
expect(fields).toContain('email');
});
it('covers Inquiry model with phone', () => {
const inquiryConfig = service.getModelConfig('Inquiry');
expect(inquiryConfig).toBeDefined();
expect(inquiryConfig!.fields.map((f) => f.field)).toContain('phone');
});
it('marks User.email and User.phone as searchable', () => {
const userConfig = service.getModelConfig('User')!;
expect(userConfig.fields.find((f) => f.field === 'email')?.searchable).toBe(true);
expect(userConfig.fields.find((f) => f.field === 'phone')?.searchable).toBe(true);
expect(userConfig.fields.find((f) => f.field === 'kycData')?.searchable).toBeFalsy();
});
it('marks Lead.email and Lead.phone as searchable', () => {
const leadConfig = service.getModelConfig('Lead')!;
expect(leadConfig.fields.find((f) => f.field === 'email')?.searchable).toBe(true);
expect(leadConfig.fields.find((f) => f.field === 'phone')?.searchable).toBe(true);
});
});
});

View File

@@ -141,7 +141,7 @@ describe('validateEnv', () => {
process.env['DATABASE_URL'] = 'postgresql://localhost/goodgo';
process.env['CORS_ORIGINS'] = 'https://goodgo.vn';
process.env['REDIS_HOST'] = 'redis.internal';
process.env['KYC_ENCRYPTION_KEY'] = 'a'.repeat(64); // 32 bytes hex
process.env['FIELD_ENCRYPTION_KEY'] = 'a'.repeat(64); // 32 bytes hex
expect(() => validateEnv()).not.toThrow();
});

View File

@@ -0,0 +1,250 @@
import crypto from 'node:crypto';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { isEncrypted } from '../field-encryption';
import { FieldEncryptionService, PII_FIELD_MAP } from '../field-encryption.service';
// Mock LoggerService
const mockLogger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
verbose: vi.fn(),
child: vi.fn(),
} as any;
const TEST_KEY = crypto.randomBytes(32).toString('hex');
describe('FieldEncryptionService', () => {
describe('when encryption key is configured', () => {
let service: FieldEncryptionService;
beforeEach(() => {
process.env['FIELD_ENCRYPTION_KEY'] = TEST_KEY;
process.env['FIELD_ENCRYPTION_KEY_VERSION'] = '1';
service = new FieldEncryptionService(mockLogger);
});
afterEach(() => {
delete process.env['FIELD_ENCRYPTION_KEY'];
delete process.env['FIELD_ENCRYPTION_KEY_VERSION'];
delete process.env['KYC_ENCRYPTION_KEY'];
delete process.env['KYC_ENCRYPTION_KEY_VERSION'];
});
it('should be enabled', () => {
expect(service.isEnabled()).toBe(true);
});
describe('encrypt/decrypt round-trip', () => {
it('encrypts and decrypts a string', () => {
const original = 'test@example.com';
const encrypted = service.encrypt(original) as string;
expect(isEncrypted(encrypted)).toBe(true);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(original);
});
it('encrypts and decrypts an object', () => {
const original = { name: 'Nguyen Van A', idNumber: '012345678901' };
const encrypted = service.encrypt(original) as string;
expect(isEncrypted(encrypted)).toBe(true);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toEqual(original);
});
it('returns null/undefined unchanged', () => {
expect(service.encrypt(null)).toBeNull();
expect(service.encrypt(undefined)).toBeUndefined();
expect(service.decrypt(null)).toBeNull();
expect(service.decrypt(undefined)).toBeUndefined();
});
it('produces different ciphertext for same input (random IV)', () => {
const value = 'same value';
const enc1 = service.encrypt(value);
const enc2 = service.encrypt(value);
expect(enc1).not.toBe(enc2);
expect(service.decrypt(enc1)).toBe(value);
expect(service.decrypt(enc2)).toBe(value);
});
});
describe('plaintext passthrough', () => {
it('returns non-encrypted strings unchanged on decrypt', () => {
expect(service.decrypt('plain text')).toBe('plain text');
});
it('returns non-string values unchanged on decrypt', () => {
const obj = { name: 'test' };
expect(service.decrypt(obj)).toBe(obj);
expect(service.decrypt(42)).toBe(42);
});
});
describe('isAlreadyEncrypted', () => {
it('detects encrypted values', () => {
const encrypted = service.encrypt('test') as string;
expect(service.isAlreadyEncrypted(encrypted)).toBe(true);
});
it('rejects plaintext values', () => {
expect(service.isAlreadyEncrypted('plain')).toBe(false);
expect(service.isAlreadyEncrypted(null)).toBe(false);
expect(service.isAlreadyEncrypted(42)).toBe(false);
});
});
describe('computeHash', () => {
it('produces a deterministic hex hash', () => {
const hash1 = service.computeHash('test@example.com');
const hash2 = service.computeHash('test@example.com');
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^[0-9a-f]{64}$/);
});
it('normalizes input (case-insensitive, trimmed)', () => {
const hash1 = service.computeHash('Test@Example.COM');
const hash2 = service.computeHash(' test@example.com ');
expect(hash1).toBe(hash2);
});
it('returns null for null/undefined', () => {
expect(service.computeHash(null)).toBeNull();
expect(service.computeHash(undefined)).toBeNull();
});
it('produces different hashes for different values', () => {
const hash1 = service.computeHash('user1@example.com');
const hash2 = service.computeHash('user2@example.com');
expect(hash1).not.toBe(hash2);
});
});
describe('key rotation support', () => {
it('decrypts with version from encrypted string', () => {
const encrypted = service.encrypt('secret data') as string;
expect(encrypted).toMatch(/^enc:v1:/);
expect(service.decrypt(encrypted)).toBe('secret data');
});
it('uses versioned key config', () => {
process.env['FIELD_ENCRYPTION_KEY_VERSION'] = '3';
const v3Service = new FieldEncryptionService(mockLogger);
const encrypted = v3Service.encrypt('test') as string;
expect(encrypted).toMatch(/^enc:v3:/);
expect(v3Service.decrypt(encrypted)).toBe('test');
});
});
describe('logAccess', () => {
it('logs encrypt operations', () => {
service.logAccess('encrypt', 'User', ['email', 'phone'], 'user-123');
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('encrypt User.{email,phone}'),
'FieldEncryptionService',
);
});
it('logs decrypt operations', () => {
service.logAccess('decrypt', 'Payment', ['callbackData']);
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('decrypt Payment.{callbackData}'),
'FieldEncryptionService',
);
});
});
describe('getFieldMap', () => {
it('returns PII_FIELD_MAP', () => {
expect(service.getFieldMap()).toBe(PII_FIELD_MAP);
});
});
describe('getModelConfig', () => {
it('finds config for known models', () => {
const userConfig = service.getModelConfig('User');
expect(userConfig).toBeDefined();
expect(userConfig!.fields).toHaveLength(3);
});
it('returns undefined for unknown models', () => {
expect(service.getModelConfig('Unknown')).toBeUndefined();
});
});
});
describe('when encryption key is NOT configured', () => {
let service: FieldEncryptionService;
beforeEach(() => {
delete process.env['FIELD_ENCRYPTION_KEY'];
delete process.env['KYC_ENCRYPTION_KEY'];
service = new FieldEncryptionService(mockLogger);
});
it('should not be enabled', () => {
expect(service.isEnabled()).toBe(false);
});
it('encrypt returns value unchanged', () => {
expect(service.encrypt('test')).toBe('test');
expect(service.encrypt({ data: 1 })).toEqual({ data: 1 });
});
it('decrypt returns value unchanged', () => {
expect(service.decrypt('test')).toBe('test');
});
it('computeHash returns null', () => {
expect(service.computeHash('test@example.com')).toBeNull();
});
});
describe('KYC_ENCRYPTION_KEY fallback', () => {
it('uses KYC_ENCRYPTION_KEY when FIELD_ENCRYPTION_KEY is not set', () => {
delete process.env['FIELD_ENCRYPTION_KEY'];
process.env['KYC_ENCRYPTION_KEY'] = TEST_KEY;
const service = new FieldEncryptionService(mockLogger);
expect(service.isEnabled()).toBe(true);
const encrypted = service.encrypt('fallback test') as string;
expect(isEncrypted(encrypted)).toBe(true);
expect(service.decrypt(encrypted)).toBe('fallback test');
delete process.env['KYC_ENCRYPTION_KEY'];
});
});
describe('PII_FIELD_MAP correctness', () => {
it('covers all required models', () => {
const models = PII_FIELD_MAP.map((c) => c.model);
expect(models).toContain('User');
expect(models).toContain('Agent');
expect(models).toContain('Payment');
expect(models).toContain('Lead');
expect(models).toContain('Inquiry');
});
it('User model has correct fields', () => {
const userConfig = PII_FIELD_MAP.find((c) => c.model === 'User')!;
const fieldNames = userConfig.fields.map((f) => f.field);
expect(fieldNames).toContain('email');
expect(fieldNames).toContain('phone');
expect(fieldNames).toContain('kycData');
});
it('searchable fields are marked correctly', () => {
const userConfig = PII_FIELD_MAP.find((c) => c.model === 'User')!;
const emailField = userConfig.fields.find((f) => f.field === 'email');
const phoneField = userConfig.fields.find((f) => f.field === 'phone');
const kycField = userConfig.fields.find((f) => f.field === 'kycData');
expect(emailField?.searchable).toBe(true);
expect(phoneField?.searchable).toBe(true);
expect(kycField?.searchable).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,197 @@
/**
* Prisma query-extension that transparently encrypts PII fields on write and
* decrypts them on read. Works via Prisma `$extends({ query: … })`.
*
* Design principles:
* - Zero changes required in business logic / repositories
* - Searchable fields also get a `{field}Hash` written on create/update
* - Decryption is applied to all query results automatically
* - Non-encrypted (plaintext) values pass through unchanged — safe for
* incremental migration
*/
import { Prisma } from '@prisma/client';
import {
type FieldEncryptionService,
type ModelEncryptionConfig,
type ModelEncryptionFieldConfig,
} from './field-encryption.service';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function encryptDataObject(
data: Record<string, unknown>,
fields: ModelEncryptionFieldConfig[],
service: FieldEncryptionService,
): void {
const encryptedFields: string[] = [];
for (const fieldConfig of fields) {
const value = data[fieldConfig.field];
if (value === undefined || value === null) continue;
// Skip if already encrypted (idempotent)
if (service.isAlreadyEncrypted(value)) continue;
// Compute deterministic hash for searchable fields BEFORE encryption
if (fieldConfig.searchable && typeof value === 'string') {
data[`${fieldConfig.field}Hash`] = service.computeHash(value);
}
data[fieldConfig.field] = service.encrypt(value);
encryptedFields.push(fieldConfig.field);
}
if (encryptedFields.length > 0) {
service.logAccess('encrypt', 'write', encryptedFields);
}
}
function decryptRow(
row: Record<string, unknown>,
fields: ModelEncryptionFieldConfig[],
service: FieldEncryptionService,
): void {
const decryptedFields: string[] = [];
for (const fieldConfig of fields) {
const value = row[fieldConfig.field];
if (value === undefined || value === null) continue;
if (service.isAlreadyEncrypted(value)) {
row[fieldConfig.field] = service.decrypt(value);
decryptedFields.push(fieldConfig.field);
}
}
if (decryptedFields.length > 0) {
service.logAccess('decrypt', 'read', decryptedFields);
}
}
function decryptResult(
result: unknown,
config: ModelEncryptionConfig,
service: FieldEncryptionService,
): void {
if (Array.isArray(result)) {
for (const item of result) {
if (typeof item === 'object' && item !== null) {
decryptRow(item as Record<string, unknown>, config.fields, service);
}
}
} else if (typeof result === 'object' && result !== null) {
decryptRow(result as Record<string, unknown>, config.fields, service);
}
}
// ---------------------------------------------------------------------------
// Write-args encryption
// ---------------------------------------------------------------------------
function encryptWriteArgs(
args: Record<string, unknown>,
action: string,
config: ModelEncryptionConfig,
service: FieldEncryptionService,
): void {
if (action === 'createMany' || action === 'createManyAndReturn') {
const data = args['data'];
if (Array.isArray(data)) {
for (const row of data) {
encryptDataObject(row as Record<string, unknown>, config.fields, service);
}
}
return;
}
if (action === 'upsert') {
const create = args['create'] as Record<string, unknown> | undefined;
const update = args['update'] as Record<string, unknown> | undefined;
if (create) encryptDataObject(create, config.fields, service);
if (update) encryptDataObject(update, config.fields, service);
return;
}
// create, update, updateMany — args.data
const data = args['data'] as Record<string, unknown> | undefined;
if (data) {
encryptDataObject(data, config.fields, service);
}
}
// Prisma actions that write data
const WRITE_ACTIONS = new Set([
'create',
'createMany',
'createManyAndReturn',
'update',
'updateMany',
'upsert',
]);
// Prisma actions whose results we should decrypt
const READ_ACTIONS = new Set([
'findUnique',
'findUniqueOrThrow',
'findFirst',
'findFirstOrThrow',
'findMany',
'create',
'createManyAndReturn',
'update',
'upsert',
'delete',
]);
// ---------------------------------------------------------------------------
// Public: create the Prisma extension
// ---------------------------------------------------------------------------
/**
* Creates a Prisma query extension for field-level encryption.
*
* Usage inside PrismaService:
* ```ts
* const extended = prisma.$extends(createEncryptionExtension(service));
* ```
*/
export function createEncryptionExtension(service: FieldEncryptionService) {
// Build a fast lookup: lowercase model name → config
const modelLookup = new Map<string, ModelEncryptionConfig>();
for (const config of service.getFieldMap()) {
modelLookup.set(config.model.toLowerCase(), config);
}
return Prisma.defineExtension({
name: 'field-encryption',
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
// Look up encryption config for this model
const config = model ? modelLookup.get(model.toLowerCase()) : undefined;
if (!config || !service.isEnabled()) {
return query(args);
}
// Encrypt on write
if (WRITE_ACTIONS.has(operation) && args) {
encryptWriteArgs(args as Record<string, unknown>, operation, config, service);
}
const result = await query(args);
// Decrypt on read
if (READ_ACTIONS.has(operation) && result !== null && result !== undefined) {
decryptResult(result, config, service);
}
return result;
},
},
},
});
}

View File

@@ -0,0 +1,219 @@
/**
* NestJS-injectable field encryption service.
*
* Wraps the low-level AES-256-GCM encrypt/decrypt functions with:
* - Multi-key support for key rotation
* - Deterministic hashing for indexed lookups (email, phone)
* - Per-model/field configuration
* - Access audit logging
*/
import crypto from 'node:crypto';
import { Injectable } from '@nestjs/common';
import {
encryptField,
decryptField,
isEncrypted,
type FieldEncryptionConfig,
} from './field-encryption';
import { type LoggerService } from './logger.service';
// ---------------------------------------------------------------------------
// Configuration types
// ---------------------------------------------------------------------------
export interface EncryptionKeyConfig {
/** 32-byte hex-encoded encryption key (64 hex chars). */
key: string;
/** Key version — newer is higher. */
version: number;
}
/** Describes which fields on a Prisma model are encrypted. */
export interface ModelEncryptionFieldConfig {
/** The database field name. */
field: string;
/**
* If true, a deterministic HMAC-SHA256 hash is also maintained in a
* `{field}Hash` column, enabling indexed lookups on encrypted data.
*/
searchable?: boolean;
}
export interface ModelEncryptionConfig {
/** Prisma model name (PascalCase, e.g. "User"). */
model: string;
/** Fields to encrypt within this model. */
fields: ModelEncryptionFieldConfig[];
}
// ---------------------------------------------------------------------------
// Encrypted-field map — the single source of truth
// ---------------------------------------------------------------------------
/**
* Master configuration of all PII fields that require encryption.
*
* - `searchable: true` means a deterministic hash column (`{field}Hash`)
* exists to support `WHERE` / unique-index lookups.
* - JSON/blob fields are never searchable (their data is opaque).
*/
export const PII_FIELD_MAP: ModelEncryptionConfig[] = [
{
model: 'User',
fields: [
{ field: 'email', searchable: true },
{ field: 'phone', searchable: true },
{ field: 'kycData' },
],
},
{
model: 'Agent',
fields: [{ field: 'licenseNumber' }],
},
{
model: 'Payment',
fields: [
{ field: 'providerTxId' },
{ field: 'callbackData' },
],
},
{
model: 'Lead',
fields: [
{ field: 'phone', searchable: true },
{ field: 'email', searchable: true },
],
},
{
model: 'Inquiry',
fields: [{ field: 'phone' }],
},
];
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@Injectable()
export class FieldEncryptionService {
private readonly activeConfig: FieldEncryptionConfig | null;
/** All known key configs, indexed by version — used for decryption. */
private readonly keysByVersion: Map<number, FieldEncryptionConfig>;
/** HMAC key derived from the active encryption key (for deterministic hashes). */
private readonly hmacKey: Buffer | null;
private readonly enabled: boolean;
constructor(private readonly logger: LoggerService) {
const primaryKey = process.env['FIELD_ENCRYPTION_KEY'] ?? process.env['KYC_ENCRYPTION_KEY'];
const keyVersion = Number(
process.env['FIELD_ENCRYPTION_KEY_VERSION'] ?? process.env['KYC_ENCRYPTION_KEY_VERSION'] ?? '1',
);
if (!primaryKey) {
this.activeConfig = null;
this.keysByVersion = new Map();
this.hmacKey = null;
this.enabled = false;
return;
}
this.activeConfig = { key: primaryKey, keyVersion: keyVersion };
this.keysByVersion = new Map([[keyVersion, this.activeConfig]]);
// Load previous key versions for decryption (FIELD_ENCRYPTION_KEY_PREV_1, _PREV_2, ...)
for (let i = 1; i <= 10; i++) {
const prevKey = process.env[`FIELD_ENCRYPTION_KEY_PREV_${i}`];
const prevVer = Number(process.env[`FIELD_ENCRYPTION_KEY_PREV_${i}_VERSION`] ?? `${keyVersion - i}`);
if (prevKey) {
this.keysByVersion.set(prevVer, { key: prevKey, keyVersion: prevVer });
}
}
// Derive a stable HMAC key from the primary encryption key for deterministic hashing.
// We use HKDF to derive a separate key so the HMAC key is distinct from the encryption key.
this.hmacKey = crypto.hkdfSync(
'sha256',
Buffer.from(primaryKey, 'hex'),
Buffer.alloc(0), // no salt — deterministic derivation
Buffer.from('goodgo-field-hash', 'utf8'),
32,
) as unknown as Buffer;
this.enabled = true;
}
/** Whether encryption is configured and active. */
isEnabled(): boolean {
return this.enabled;
}
/** Encrypt a value using the active key. Returns the `enc:v…:…` string. */
encrypt(value: unknown): unknown {
if (!this.activeConfig || value === null || value === undefined) return value;
return encryptField(value, this.activeConfig);
}
/**
* Decrypt a value. Automatically selects the correct key version from the
* `enc:v{N}:…` prefix. Falls back to the active key if version lookup fails.
* Non-encrypted values pass through unchanged (migration-safe).
*/
decrypt(stored: unknown): unknown {
if (!this.enabled || stored === null || stored === undefined) return stored;
if (!isEncrypted(stored)) return stored;
// Parse version from the stored value
const version = this.parseVersion(stored as string);
const config = (version !== null ? this.keysByVersion.get(version) : null) ?? this.activeConfig!;
return decryptField(stored, config);
}
/** Check whether a stored value is already encrypted. */
isAlreadyEncrypted(value: unknown): boolean {
return isEncrypted(value);
}
/**
* Compute a deterministic HMAC-SHA256 hash for indexed lookups.
* The value is normalized (lowercased, trimmed) before hashing.
*/
computeHash(value: string | null | undefined): string | null {
if (!this.hmacKey || value === null || value === undefined) return null;
const normalized = value.toLowerCase().trim();
return crypto.createHmac('sha256', this.hmacKey).update(normalized).digest('hex');
}
/** Log an audit entry for access to encrypted fields. */
logAccess(
operation: 'encrypt' | 'decrypt',
model: string,
fields: string[],
recordId?: string,
): void {
this.logger.debug(
`[field-encryption] ${operation} ${model}.{${fields.join(',')}}${recordId ? ` id=${recordId}` : ''}`,
'FieldEncryptionService',
);
}
/** Get the full PII field configuration. */
getFieldMap(): ModelEncryptionConfig[] {
return PII_FIELD_MAP;
}
/** Find encryption config for a specific model. */
getModelConfig(modelName: string): ModelEncryptionConfig | undefined {
return PII_FIELD_MAP.find((c) => c.model === modelName);
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private parseVersion(encrypted: string): number | null {
// Format: enc:v{N}:{iv}:{authTag}:{ciphertext}
const match = encrypted.match(/^enc:v(\d+):/);
return match ? Number(match[1]) : null;
}
}

View File

@@ -2,9 +2,9 @@ import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/com
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import pg from 'pg';
import { FieldEncryptionService } from './field-encryption.service';
import { createEncryptionExtension } from './encryption-middleware';
import { LoggerService } from './logger.service';
import { FieldEncryptionService } from './field-encryption.service';
import { type LoggerService } from './logger.service';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {

View File

@@ -10,13 +10,13 @@ import {
CACHE_DEGRADATION_TOTAL,
} from './infrastructure/cache.service';
import { EventBusService } from './infrastructure/event-bus.service';
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
import { LoggerService } from './infrastructure/logger.service';
import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware';
import { CsrfMiddleware } from './infrastructure/middleware/csrf.middleware';
import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware';
import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware';
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
import { PrismaService } from './infrastructure/prisma.service';
import { RedisService } from './infrastructure/redis.service';