feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests
- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow - Add PII field encryption middleware with AES-256-GCM and deterministic search hashes - Add agents, inquiries, and leads domain modules with entities, events, value objects - Add web dashboard pages for inquiries and leads with detail dialogs - Add 30+ component tests (valuation, charts, listings, search, providers, UI) - Add Prisma migrations for encryption hash columns and MFA TOTP support - Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes) - Update dependencies and lock file - Clean up obsolete exploration/QA docs, add audit documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -4,3 +4,8 @@ export {
|
||||
type TokenPair,
|
||||
type RotateResult,
|
||||
} from './token.service';
|
||||
export {
|
||||
MfaService,
|
||||
type MfaSetupResult,
|
||||
type BackupCodeResult,
|
||||
} from './mfa.service';
|
||||
|
||||
118
apps/api/src/modules/auth/infrastructure/services/mfa.service.ts
Normal file
118
apps/api/src/modules/auth/infrastructure/services/mfa.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createHmac, randomBytes } from 'crypto';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { generateSecret, generateURI, verify } from 'otplib';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
const TOTP_ISSUER = 'GoodGo Platform';
|
||||
const BACKUP_CODE_COUNT = 10;
|
||||
const BACKUP_CODE_LENGTH = 8;
|
||||
const TOTP_EPOCH_TOLERANCE = 30; // 1-step clock skew (30 seconds)
|
||||
|
||||
export interface MfaSetupResult {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
qrCodeDataUrl: string;
|
||||
}
|
||||
|
||||
export interface BackupCodeResult {
|
||||
codes: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MfaService {
|
||||
private readonly logger = new Logger(MfaService.name);
|
||||
|
||||
/**
|
||||
* Generate a new TOTP secret and QR code for setup.
|
||||
*/
|
||||
async generateSetup(userIdentifier: string): Promise<MfaSetupResult> {
|
||||
const secret = generateSecret();
|
||||
const otpauthUrl = generateURI({
|
||||
issuer: TOTP_ISSUER,
|
||||
label: userIdentifier,
|
||||
secret,
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
|
||||
|
||||
return { secret, otpauthUrl, qrCodeDataUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TOTP code against a secret.
|
||||
* Returns true if valid within the configured window.
|
||||
*/
|
||||
async verifyTotp(token: string, secret: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await verify({
|
||||
secret,
|
||||
token,
|
||||
epochTolerance: TOTP_EPOCH_TOLERANCE,
|
||||
});
|
||||
return result.valid;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`TOTP verification error: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup codes.
|
||||
* Returns plaintext codes (to show to user) and hashed versions (to store).
|
||||
*/
|
||||
generateBackupCodes(): { plainCodes: string[]; hashedCodes: string[] } {
|
||||
const plainCodes: string[] = [];
|
||||
const hashedCodes: string[] = [];
|
||||
|
||||
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
||||
const code = this.generateReadableCode(BACKUP_CODE_LENGTH);
|
||||
plainCodes.push(code);
|
||||
hashedCodes.push(this.hashBackupCode(code));
|
||||
}
|
||||
|
||||
return { plainCodes, hashedCodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a backup code against a list of hashed codes.
|
||||
* Returns the index of the matching code, or -1 if not found.
|
||||
*/
|
||||
verifyBackupCode(code: string, hashedCodes: string[]): number {
|
||||
const normalizedCode = code.replace(/[\s-]/g, '').toUpperCase();
|
||||
const hashedInput = this.hashBackupCode(normalizedCode);
|
||||
|
||||
for (let i = 0; i < hashedCodes.length; i++) {
|
||||
if (hashedCodes[i] === hashedInput) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable alphanumeric code (excluding ambiguous characters).
|
||||
*/
|
||||
private generateReadableCode(length: number): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, I, 1
|
||||
const bytes = randomBytes(length);
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[bytes[i]! % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a backup code using HMAC-SHA256.
|
||||
* Uses a fixed key derived from the app secret for consistent hashing.
|
||||
*/
|
||||
private hashBackupCode(code: string): string {
|
||||
const secret = process.env['MFA_BACKUP_CODE_SECRET'] || process.env['JWT_SECRET'] || 'goodgo-mfa-backup-default';
|
||||
return createHmac('sha256', secret).update(code).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,10 @@ export class OAuthService {
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive: true,
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
});
|
||||
|
||||
await this.userRepo.save(user);
|
||||
|
||||
Reference in New Issue
Block a user