Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import { generateSecret, generateURI, verifySync } from 'otplib';
|
|
import * as QRCode from 'qrcode';
|
|
import { randomBytes } from 'crypto';
|
|
|
|
@Injectable()
|
|
export class MfaService {
|
|
private readonly logger = new Logger(MfaService.name);
|
|
|
|
constructor(private dataSource: DataSource) {}
|
|
|
|
/**
|
|
* Generate MFA setup data (secret + QR code) for a user.
|
|
*/
|
|
async generateSetup(userId: string): Promise<{ secret: string; qrDataUrl: string; otpauthUrl: string }> {
|
|
const userRows = await this.dataSource.query(
|
|
`SELECT email, mfa_enabled FROM shared.users WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
if (userRows.length === 0) throw new BadRequestException('User not found');
|
|
|
|
const secret = generateSecret();
|
|
const otpauthUrl = generateURI({ secret, issuer: 'HOA LedgerIQ', label: userRows[0].email });
|
|
const qrDataUrl = await QRCode.toDataURL(otpauthUrl);
|
|
|
|
// Store the secret temporarily (not verified yet)
|
|
await this.dataSource.query(
|
|
`UPDATE shared.users SET mfa_secret = $1, updated_at = NOW() WHERE id = $2`,
|
|
[secret, userId],
|
|
);
|
|
|
|
return { secret, qrDataUrl, otpauthUrl };
|
|
}
|
|
|
|
/**
|
|
* Enable MFA after verifying the initial TOTP code.
|
|
* Returns recovery codes.
|
|
*/
|
|
async enableMfa(userId: string, token: string): Promise<{ recoveryCodes: string[] }> {
|
|
const userRows = await this.dataSource.query(
|
|
`SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
if (userRows.length === 0) throw new BadRequestException('User not found');
|
|
if (!userRows[0].mfa_secret) throw new BadRequestException('MFA setup not initiated');
|
|
if (userRows[0].mfa_enabled) throw new BadRequestException('MFA is already enabled');
|
|
|
|
// Verify the token
|
|
const result = verifySync({ token, secret: userRows[0].mfa_secret });
|
|
if (!result.valid) throw new BadRequestException('Invalid verification code');
|
|
|
|
// Generate recovery codes
|
|
const recoveryCodes = Array.from({ length: 10 }, () =>
|
|
randomBytes(4).toString('hex').toUpperCase(),
|
|
);
|
|
|
|
// Hash recovery codes for storage
|
|
const hashedCodes = await Promise.all(
|
|
recoveryCodes.map((code) => bcrypt.hash(code, 10)),
|
|
);
|
|
|
|
// Enable MFA
|
|
await this.dataSource.query(
|
|
`UPDATE shared.users SET
|
|
mfa_enabled = true,
|
|
totp_verified_at = NOW(),
|
|
recovery_codes = $1,
|
|
updated_at = NOW()
|
|
WHERE id = $2`,
|
|
[JSON.stringify(hashedCodes), userId],
|
|
);
|
|
|
|
this.logger.log(`MFA enabled for user ${userId}`);
|
|
return { recoveryCodes };
|
|
}
|
|
|
|
/**
|
|
* Verify a TOTP code during login.
|
|
*/
|
|
async verifyMfa(userId: string, token: string): Promise<boolean> {
|
|
const userRows = await this.dataSource.query(
|
|
`SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
if (userRows.length === 0 || !userRows[0].mfa_enabled) return false;
|
|
|
|
const result = verifySync({ token, secret: userRows[0].mfa_secret });
|
|
return result.valid;
|
|
}
|
|
|
|
/**
|
|
* Verify a recovery code (consumes it on success).
|
|
*/
|
|
async verifyRecoveryCode(userId: string, code: string): Promise<boolean> {
|
|
const userRows = await this.dataSource.query(
|
|
`SELECT recovery_codes FROM shared.users WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
if (userRows.length === 0 || !userRows[0].recovery_codes) return false;
|
|
|
|
const hashedCodes: string[] = JSON.parse(userRows[0].recovery_codes);
|
|
|
|
for (let i = 0; i < hashedCodes.length; i++) {
|
|
const match = await bcrypt.compare(code.toUpperCase(), hashedCodes[i]);
|
|
if (match) {
|
|
// Remove the used code
|
|
hashedCodes.splice(i, 1);
|
|
await this.dataSource.query(
|
|
`UPDATE shared.users SET recovery_codes = $1, updated_at = NOW() WHERE id = $2`,
|
|
[JSON.stringify(hashedCodes), userId],
|
|
);
|
|
this.logger.log(`Recovery code used for user ${userId}`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Disable MFA (requires password verification done by caller).
|
|
*/
|
|
async disableMfa(userId: string): Promise<void> {
|
|
await this.dataSource.query(
|
|
`UPDATE shared.users SET
|
|
mfa_enabled = false,
|
|
mfa_secret = NULL,
|
|
totp_verified_at = NULL,
|
|
recovery_codes = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
this.logger.log(`MFA disabled for user ${userId}`);
|
|
}
|
|
|
|
/**
|
|
* Get MFA status for a user.
|
|
*/
|
|
async getStatus(userId: string): Promise<{ enabled: boolean; hasRecoveryCodes: boolean }> {
|
|
const rows = await this.dataSource.query(
|
|
`SELECT mfa_enabled, recovery_codes FROM shared.users WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
if (rows.length === 0) return { enabled: false, hasRecoveryCodes: false };
|
|
|
|
return {
|
|
enabled: rows[0].mfa_enabled || false,
|
|
hasRecoveryCodes: !!rows[0].recovery_codes && JSON.parse(rows[0].recovery_codes || '[]').length > 0,
|
|
};
|
|
}
|
|
}
|