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

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