/** * TASK-0133 — 2FA enrollment/verification for the org `require2FA` policy. * * The seed lives on `User.twoFactorSecret` as an AES-256-GCM base64 * envelope (same scheme/key as SSO + webhook secrets — `MASTER_ENCRYPTION_ * KEY` via `lib/crypto.ts`); the base32 string is revealed EXACTLY ONCE in * the enrollment response (QR data `otpauth://totp/…`) — it is never read * back. A missing `MASTER_ENCRYPTION_KEY` is a 500 (fail-closed). * * Verification at LOGIN (the enforcement point): a user in ANY org with * `require2FA: true` (see `resolveUserPolicy`) must present a valid `totp` * code — `login()` returns `202 { reason: "2fa_required", verificationId }` * and `POST /auth/2fa/verify` completes the login (issues the session). The * pending 2FA step is a short-lived (5 min, single-use) Redis token, so a * wrong password never learns anything about a user's TOTP state. */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { aes256gcmDecrypt, aes256gcmEncrypt } from "../../lib/crypto.js"; import { ApiError } from "../../common/errors.js"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { loadEnv } from "../../config/env.js"; import { generateTotpSeed, totpVerify } from "./totp.js"; export const TWO_FA_PENDING_TTL_SEC = 300; // 5 min, single-use const pendingKey = (id: string) => `auth:2fa:pending:${id}`; @Injectable() export class TwoFactorService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService, @Inject(AuditService) private readonly audit: AuditService ) {} private key(): string { const k = loadEnv().MASTER_ENCRYPTION_KEY; if (!k) { throw new ApiError( "INTERNAL_ERROR", 500, "MASTER_ENCRYPTION_KEY is not configured — 2FA secrets cannot be stored." ); } return k; } /** The decrypted TOTP seed (null when not enrolled). */ async getSeed(userId: string): Promise { const row = await this.prisma.user.findUnique({ where: { id: userId }, select: { twoFactorSecret: true } }); if (!row?.twoFactorSecret) return null; try { return Buffer.from(aes256gcmDecrypt(row.twoFactorSecret, this.key()), "base64"); } catch { // A corrupt envelope must not 500 login — treat as enrolled-with- // broken-seed: fail closed (require re-enrollment) but report 500 // honestly (the admin can reset it). throw new ApiError("INTERNAL_ERROR", 500, "Stored 2FA secret is unreadable; reset 2FA enrollment."); } } /** Enrollment: returns the revealed-once secret + otpauth URI. */ async enroll(userId: string, req?: Request): Promise<{ secret: string; otpauthUri: string; confirmed: false }> { const { seed, secret } = generateTotpSeed(); const envelope = aes256gcmEncrypt(seed.toString("base64"), this.key()); await this.prisma.user.update({ where: { id: userId }, data: { twoFactorSecret: envelope } }); const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); const host = new URL(loadEnv().WEB_ORIGIN).host; const otpauthUri = `otpauth://totp/${encodeURIComponent(host)}:${encodeURIComponent( user?.email ?? userId )}?secret=${secret}&issuer=${encodeURIComponent(host)}&algorithm=SHA1&digits=6&period=30`; await this.audit.audit(req, req?.auth, { action: "user.2fa_enrolled", resourceType: "user", resourceId: userId, metadata: { method: "totp" } }); return { secret, otpauthUri, confirmed: false }; } /** Disenroll (self or org-admin reset). */ async disable(userId: string, req?: Request): Promise { await this.prisma.user.update({ where: { id: userId }, data: { twoFactorSecret: null } }); await this.audit.audit(req, req?.auth, { action: "user.2fa_disabled", resourceType: "user", resourceId: userId, metadata: {} }); } async isEnrolled(userId: string): Promise { const row = await this.prisma.user.findUnique({ where: { id: userId }, select: { twoFactorSecret: true } }); return row?.twoFactorSecret != null; } /** * Create a pending 2FA step for a user who passed the password (login * returned 202). Returns the `verificationId` (random 32 bytes, b64url). */ async createPending(userId: string): Promise { const id = Buffer.from(generateTotpSeed().seed).toString("base64url"); await this.redis.getRedis().set(pendingKey(id), userId, "EX", TWO_FA_PENDING_TTL_SEC); return id; } /** * Verify + consume a pending step. Returns the userId on success. * Consumes atomically (GETDEL) so the step is single-use. */ async verifyPending(verificationId: string, code: string): Promise { const r = this.redis.getRedis(); // GETDEL is atomic (Redis ≥ 6.2; we run 7) — ioredis's typings predate it, // hence the cast. A GET-then-DEL race would allow a replay window. const stored = await (r as unknown as { getDel(k: string): Promise }).getDel( pendingKey(verificationId) ); if (!stored) throw ApiError.unauthorized("2FA verification expired.", [{ reason: "2fa_pending_expired" }]); const seed = await this.getSeed(stored); if (!seed || !totpVerify(seed, code)) { throw ApiError.unauthorized("Invalid 2FA code.", [{ reason: "2fa_code_invalid" }]); } return stored; } }