diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx index 942d7f5..5caee81 100644 --- a/apps/web/src/app/auth/signin/page.tsx +++ b/apps/web/src/app/auth/signin/page.tsx @@ -10,10 +10,13 @@ export default function SignInPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [totp, setTotp] = useState(""); + const [backupCode, setBackupCode] = useState(""); + const [useBackupCode, setUseBackupCode] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [mfaRequired, setMfaRequired] = useState(false); const totpInputRef = useRef(null); + const backupCodeInputRef = useRef(null); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -23,7 +26,8 @@ export default function SignInPage() { const result = await signIn("credentials", { email, password, - ...(mfaRequired ? { totp } : {}), + ...(mfaRequired && !useBackupCode ? { totp } : {}), + ...(mfaRequired && useBackupCode ? { backupCode } : {}), redirect: false, }); @@ -47,8 +51,13 @@ export default function SignInPage() { return; } if (code === "INVALID_TOTP") { - setError("Invalid verification code. Please try again."); + setError( + useBackupCode + ? "Invalid backup code. Please try again." + : "Invalid verification code. Please try again.", + ); setTotp(""); + setBackupCode(""); setLoading(false); return; } @@ -57,6 +66,8 @@ export default function SignInPage() { if (mfaRequired) { setMfaRequired(false); setTotp(""); + setBackupCode(""); + setUseBackupCode(false); } } else { // Full-page navigation instead of router.push to guarantee a fresh @@ -76,6 +87,8 @@ export default function SignInPage() { function handleBackToLogin() { setMfaRequired(false); setTotp(""); + setBackupCode(""); + setUseBackupCode(false); setError(""); } @@ -183,7 +196,7 @@ export default function SignInPage() { )} - {mfaRequired && ( + {mfaRequired && !useBackupCode && (
)} + {mfaRequired && useBackupCode && ( +
+ + setBackupCode(e.target.value.toUpperCase().slice(0, 16))} + className="app-input text-center text-xl font-mono tracking-[0.2em] uppercase" + placeholder="XXXXX-XXXXX" + required + autoFocus + /> +

+ Each backup code works once. You'll need to regenerate your codes after using + one. +

+
+ )} + {mfaRequired && ( - +
+ + +
)} diff --git a/apps/web/src/components/security/MfaSetup.tsx b/apps/web/src/components/security/MfaSetup.tsx index 7e3d730..48951e1 100644 --- a/apps/web/src/components/security/MfaSetup.tsx +++ b/apps/web/src/components/security/MfaSetup.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from "react"; import QRCode from "qrcode"; import { trpc } from "~/lib/trpc/client.js"; -type SetupStep = "idle" | "show-secret" | "verify" | "done"; +type SetupStep = "idle" | "show-secret" | "verify" | "show-backup-codes" | "done"; export function MfaSetup() { const [step, setStep] = useState("idle"); @@ -12,6 +12,7 @@ export function MfaSetup() { const [uri, setUri] = useState(""); const [qrDataUrl, setQrDataUrl] = useState(""); const [token, setToken] = useState(""); + const [backupCodes, setBackupCodes] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -33,6 +34,7 @@ export function MfaSetup() { const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery(); const generateMutation = trpc.user.generateTotpSecret.useMutation(); const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation(); + const regenerateBackupCodesMutation = trpc.user.regenerateBackupCodes.useMutation(); async function handleGenerate() { setError(null); @@ -49,9 +51,9 @@ export function MfaSetup() { async function handleVerify() { setError(null); try { - await verifyMutation.mutateAsync({ token }); - setStep("done"); - setSuccess("MFA has been enabled successfully."); + const result = await verifyMutation.mutateAsync({ token }); + setBackupCodes(result.backupCodes ?? null); + setStep("show-backup-codes"); setSecret(""); setUri(""); setToken(""); @@ -61,33 +63,111 @@ export function MfaSetup() { } } - if (mfaStatus?.totpEnabled && step !== "done") { + async function handleRegenerateBackupCodes() { + setError(null); + try { + const result = await regenerateBackupCodesMutation.mutateAsync(); + setBackupCodes(result.codes); + setStep("show-backup-codes"); + await refetch(); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not regenerate backup codes"); + } + } + + function handleFinishBackupCodes() { + setBackupCodes(null); + setStep("done"); + setSuccess("MFA is active. Keep your backup codes in a safe place."); + } + + function copyBackupCodes() { + if (!backupCodes) return; + void navigator.clipboard.writeText(backupCodes.join("\n")); + } + + function downloadBackupCodes() { + if (!backupCodes) return; + const blob = new Blob( + [ + `CapaKraken MFA Backup Codes\nGenerated: ${new Date().toISOString()}\n\nEach code works exactly once. Keep this file somewhere safe.\n\n${backupCodes.join("\n")}\n`, + ], + { type: "text/plain" }, + ); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "capakraken-backup-codes.txt"; + a.click(); + URL.revokeObjectURL(url); + } + + if (mfaStatus?.totpEnabled && step !== "done" && step !== "show-backup-codes") { + const remaining = mfaStatus.backupCodesRemaining ?? 0; + const lowCodes = remaining <= 3; return ( -
-
-
- +
+
+
+ + + +
+
+

+ MFA Enabled +

+

+ Two-factor authentication is active on your account. +

+
+
+
+ +
+
+
+

+ Backup codes +

+

+ {remaining === 0 + ? "You have no backup codes left. Generate a new set to avoid being locked out if you lose your device." + : `You have ${remaining} backup code${remaining === 1 ? "" : "s"} remaining.`}{" "} + {lowCodes && remaining > 0 && Regenerate soon.} +

+
+
-
-

- MFA Enabled -

-

- Two-factor authentication is active on your account. -

+ {regenerateBackupCodesMutation.isPending ? "Generating…" : "Regenerate codes"} +
+ {error && ( +
+ {error} +
+ )}
); @@ -250,6 +330,53 @@ export function MfaSetup() {
)} + + {step === "show-backup-codes" && backupCodes && ( +
+
+

+ Save your backup codes +

+

+ Each code works exactly once. Store them in a password manager or print them. You will + not see them again — regenerating invalidates the whole set. +

+
+
+ {backupCodes.map((code) => ( + + {code} + + ))} +
+
+ + + +
+
+ )} ); } diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 2759d16..8c94a88 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -2,6 +2,7 @@ import { prisma } from "@capakraken/db"; import { authRateLimiter } from "@capakraken/api/middleware/rate-limit"; import { createAuditEntry } from "@capakraken/api/lib/audit"; import { logger } from "@capakraken/api/lib/logger"; +import { redeemBackupCode } from "@capakraken/api/lib/mfa-backup-code-redeem"; import { consumeTotpWindow } from "@capakraken/api/lib/totp-consume"; import NextAuth, { type NextAuthConfig } from "next-auth"; import Credentials from "next-auth/providers/credentials"; @@ -39,6 +40,10 @@ const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(1).max(128), totp: z.string().max(16).optional(), + // Backup codes are the second-factor fallback when the user has lost + // their TOTP device. Max 32 covers the 10-char code with dashes and + // accidental whitespace; anything longer is rejected before argon2. + backupCode: z.string().max(32).optional(), }); function extractClientIp(request: Request | undefined): string | null { @@ -68,7 +73,7 @@ const config = { const parsed = LoginSchema.safeParse(credentials); if (!parsed.success) return null; - const { email, password, totp } = parsed.data; + const { email, password, totp, backupCode } = parsed.data; const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true"; // Rate limit: 5 attempts per 15 min, keyed on BOTH email and @@ -156,57 +161,93 @@ const config = { return null; } - // MFA check: if TOTP is enabled, require the token + // MFA check: if TOTP is enabled, require a valid TOTP *or* a + // one-shot backup code. Backup codes are the last-resort credential + // when the user has lost their TOTP device; their redemption + // deletes the row atomically (see redeemBackupCode) so replay is + // physically impossible. if (user.totpEnabled && user.totpSecret) { - if (!totp) { - // Signal to the client that MFA is required (include userId for re-submission) + if (!totp && !backupCode) { throw new MfaRequiredError(); } - const { TOTP, Secret } = await import("otpauth"); - const totpInstance = new TOTP({ - issuer: "CapaKraken", - label: user.email, - algorithm: "SHA1", - digits: 6, - period: 30, - secret: Secret.fromBase32(user.totpSecret), - }); - - const delta = totpInstance.validate({ token: totp, window: 1 }); - if (delta === null) { - logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification"); + if (backupCode) { + const result = await redeemBackupCode(prisma, user.id, backupCode); + if (!result.accepted) { + logger.warn( + { email, reason: "invalid_backup_code" }, + "Failed MFA verification — backup code", + ); + await createAuditEntry({ + db: prisma, + entityType: "Auth", + entityId: user.id, + entityName: user.email, + action: "CREATE", + userId: user.id, + summary: "Login failed — invalid backup code", + source: "ui", + }); + throw new InvalidTotpError(); + } await createAuditEntry({ db: prisma, entityType: "Auth", entityId: user.id, entityName: user.email, - action: "CREATE", + action: "UPDATE", userId: user.id, - summary: "Login failed — invalid TOTP token", + summary: `Backup code redeemed (${result.remaining} remaining)`, source: "ui", }); - throw new InvalidTotpError(); - } + // Successful backup-code auth skips TOTP replay-window checks + // entirely — the code itself is the nonce. + } else { + const { TOTP, Secret } = await import("otpauth"); + const totpInstance = new TOTP({ + issuer: "CapaKraken", + label: user.email, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: Secret.fromBase32(user.totpSecret), + }); - // Atomic replay-guard: a single UPDATE ... WHERE lastTotpAt is null - // OR older than 30 s both serialises concurrent logins (row lock) - // and expresses the "unused window" precondition in SQL. count=0 - // means another request consumed this window first → replay. - const accepted = await consumeTotpWindow(prisma, user.id); - if (!accepted) { - logger.warn({ email, reason: "totp_replay" }, "TOTP replay attack blocked"); - await createAuditEntry({ - db: prisma, - entityType: "Auth", - entityId: user.id, - entityName: user.email, - action: "CREATE", - userId: user.id, - summary: "Login failed — TOTP replay detected", - source: "ui", - }); - throw new InvalidTotpError(); + const delta = totpInstance.validate({ token: totp!, window: 1 }); + if (delta === null) { + logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification"); + await createAuditEntry({ + db: prisma, + entityType: "Auth", + entityId: user.id, + entityName: user.email, + action: "CREATE", + userId: user.id, + summary: "Login failed — invalid TOTP token", + source: "ui", + }); + throw new InvalidTotpError(); + } + + // Atomic replay-guard: a single UPDATE ... WHERE lastTotpAt is null + // OR older than 30 s both serialises concurrent logins (row lock) + // and expresses the "unused window" precondition in SQL. count=0 + // means another request consumed this window first → replay. + const accepted = await consumeTotpWindow(prisma, user.id); + if (!accepted) { + logger.warn({ email, reason: "totp_replay" }, "TOTP replay attack blocked"); + await createAuditEntry({ + db: prisma, + entityType: "Auth", + entityId: user.id, + entityName: user.email, + action: "CREATE", + userId: user.id, + summary: "Login failed — TOTP replay detected", + source: "ui", + }); + throw new InvalidTotpError(); + } } } diff --git a/packages/api/package.json b/packages/api/package.json index a76cb3c..4125f48 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,6 +13,7 @@ "./lib/logger": "./src/lib/logger.ts", "./lib/runtime-security": "./src/lib/runtime-security.ts", "./lib/totp-consume": "./src/lib/totp-consume.ts", + "./lib/mfa-backup-code-redeem": "./src/lib/mfa-backup-code-redeem.ts", "./middleware/rate-limit": "./src/middleware/rate-limit.ts" }, "scripts": { diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts index 34ab2b9..cebbd77 100644 --- a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts @@ -41,7 +41,7 @@ describe("assistant user self-service MFA tools - enable flow", () => { it("enables TOTP through the real user router path when the token is valid", async () => { totpValidateMock.mockReturnValue(0); - const db = { + const db: Record = { user: { findUnique: vi.fn().mockResolvedValue({ id: "user_1", @@ -56,6 +56,11 @@ describe("assistant user self-service MFA tools - enable flow", () => { auditLog: { create: vi.fn().mockResolvedValue({ id: "audit_1" }), }, + mfaBackupCode: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 10 }), + }, + $transaction: vi.fn().mockImplementation(async (ops: unknown[]) => ops.map(() => ({}))), }; const ctx = createToolContext(db, SystemRole.ADMIN); @@ -99,11 +104,14 @@ describe("assistant user self-service MFA tools - enable flow", () => { summary: "Enabled TOTP MFA", }), }); - expect(JSON.parse(result.content)).toEqual({ - success: true, - enabled: true, - message: "Enabled MFA TOTP.", - }); + const parsed = JSON.parse(result.content); + expect(parsed.success).toBe(true); + expect(parsed.enabled).toBe(true); + expect(parsed.message).toBe("Enabled MFA TOTP."); + expect(parsed.backupCodes).toHaveLength(10); + for (const code of parsed.backupCodes) { + expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/); + } expect(result.action).toEqual({ type: "invalidate", scope: ["user"], diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-status.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-status.test.ts index 13751cc..8263cd9 100644 --- a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-status.test.ts +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-status.test.ts @@ -19,6 +19,9 @@ describe("assistant user self-service MFA tools - status", () => { totpEnabled: true, }), }, + mfaBackupCode: { + count: vi.fn().mockResolvedValue(3), + }, }; const ctx = createToolContext(db, SystemRole.ADMIN); @@ -30,6 +33,7 @@ describe("assistant user self-service MFA tools - status", () => { }); expect(JSON.parse(result.content)).toEqual({ totpEnabled: true, + backupCodesRemaining: 3, }); }); @@ -39,6 +43,9 @@ describe("assistant user self-service MFA tools - status", () => { user: { findUnique: vi.fn().mockResolvedValue(null), }, + mfaBackupCode: { + count: vi.fn().mockResolvedValue(0), + }, }, SystemRole.ADMIN, ); diff --git a/packages/api/src/__tests__/mfa-backup-codes.test.ts b/packages/api/src/__tests__/mfa-backup-codes.test.ts new file mode 100644 index 0000000..556ad53 --- /dev/null +++ b/packages/api/src/__tests__/mfa-backup-codes.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for the MFA backup-code generator, canonicalisation, and the + * atomic redemption helper. Together they cover the three guarantees that + * make backup codes safe: + * + * 1. High-entropy, distinct plaintexts (generator). + * 2. Canonical form is what gets hashed/compared — a user can paste the + * code with or without the dash, upper or lower case. + * 3. Redemption deletes the row under a WHERE-guard so a concurrent + * second redemption fails (replay race). + */ + +import { describe, expect, it, vi } from "vitest"; +import { + BACKUP_CODE_COUNT, + generatePlaintextBackupCodes, + hashBackupCode, + normalizeBackupCode, + verifyBackupCode, +} from "../lib/mfa-backup-codes.js"; +import { redeemBackupCode } from "../lib/mfa-backup-code-redeem.js"; + +describe("generatePlaintextBackupCodes", () => { + it("yields BACKUP_CODE_COUNT distinct codes by default", () => { + const codes = generatePlaintextBackupCodes(); + expect(codes).toHaveLength(BACKUP_CODE_COUNT); + expect(new Set(codes).size).toBe(BACKUP_CODE_COUNT); + }); + + it("formats each code as five chars, dash, five chars from the Crockford alphabet", () => { + for (const code of generatePlaintextBackupCodes(20)) { + expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/); + } + }); +}); + +describe("normalizeBackupCode", () => { + it("strips dashes and whitespace and uppercases", () => { + expect(normalizeBackupCode("ab12c-xy34z")).toBe("AB12CXY34Z"); + expect(normalizeBackupCode(" AB12C XY34Z ")).toBe("AB12CXY34Z"); + expect(normalizeBackupCode("ab12cxy34z")).toBe("AB12CXY34Z"); + }); +}); + +describe("verifyBackupCode", () => { + it("accepts the plaintext (with or without dash) that produced the hash", async () => { + const hash = await hashBackupCode("ABCDE-FGHJK"); + expect(await verifyBackupCode(hash, "ABCDE-FGHJK")).toBe(true); + expect(await verifyBackupCode(hash, "abcde-fghjk")).toBe(true); + expect(await verifyBackupCode(hash, "ABCDEFGHJK")).toBe(true); + }); + + it("rejects a different plaintext", async () => { + const hash = await hashBackupCode("ABCDE-FGHJK"); + expect(await verifyBackupCode(hash, "ZZZZZ-ZZZZZ")).toBe(false); + }); + + it("returns false rather than throwing on a malformed hash", async () => { + expect(await verifyBackupCode("not-a-real-hash", "anything")).toBe(false); + }); +}); + +describe("redeemBackupCode", () => { + it("accepts a valid code, deletes the row, and reports remaining count", async () => { + const goodHash = await hashBackupCode("GOOD1-CODE1"); + const otherHash = await hashBackupCode("OTHER-CODE2"); + const db = { + mfaBackupCode: { + findMany: vi.fn().mockResolvedValue([ + { id: "a", codeHash: otherHash }, + { id: "b", codeHash: goodHash }, + ]), + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + count: vi.fn().mockResolvedValue(1), + }, + }; + const result = await redeemBackupCode(db, "user_1", "GOOD1-CODE1"); + expect(result).toEqual({ accepted: true, remaining: 1 }); + expect(db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ + where: { id: "b", usedAt: null }, + }); + }); + + it("rejects an unknown code without deleting anything", async () => { + const db = { + mfaBackupCode: { + findMany: vi + .fn() + .mockResolvedValue([{ id: "a", codeHash: await hashBackupCode("REAL1-CODE1") }]), + deleteMany: vi.fn(), + count: vi.fn().mockResolvedValue(1), + }, + }; + const result = await redeemBackupCode(db, "user_1", "WRONG-CODE"); + expect(result.accepted).toBe(false); + expect(result.remaining).toBe(1); + expect(db.mfaBackupCode.deleteMany).not.toHaveBeenCalled(); + }); + + it("treats a racing delete (count=0) as an invalid code", async () => { + // Simulates the case where another login request redeemed this exact + // code a millisecond earlier. The SQL WHERE-guard (usedAt: null) stops + // us from deleting it twice — we must treat that as a failed attempt + // so the attacker cannot learn the code was valid. + const goodHash = await hashBackupCode("RACE1-CODE1"); + const db = { + mfaBackupCode: { + findMany: vi.fn().mockResolvedValue([{ id: "a", codeHash: goodHash }]), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + count: vi.fn().mockResolvedValue(0), + }, + }; + const result = await redeemBackupCode(db, "user_1", "RACE1-CODE1"); + expect(result.accepted).toBe(false); + }); + + it("returns accepted:false / remaining:0 when the user has no codes", async () => { + const db = { + mfaBackupCode: { + findMany: vi.fn().mockResolvedValue([]), + deleteMany: vi.fn(), + count: vi.fn().mockResolvedValue(0), + }, + }; + const result = await redeemBackupCode(db, "user_1", "ANY-CODE"); + expect(result).toEqual({ accepted: false, remaining: 0 }); + }); +}); diff --git a/packages/api/src/__tests__/user-procedure-support.test.ts b/packages/api/src/__tests__/user-procedure-support.test.ts index 42d0472..c392c7a 100644 --- a/packages/api/src/__tests__/user-procedure-support.test.ts +++ b/packages/api/src/__tests__/user-procedure-support.test.ts @@ -40,13 +40,15 @@ describe("user-procedure-support", () => { }); it("lists assignable users with the expected lightweight selection", async () => { - const findMany = vi.fn().mockResolvedValue([ - { id: "user_1", name: "Alice", email: "alice@example.com" }, - ]); + const findMany = vi + .fn() + .mockResolvedValue([{ id: "user_1", name: "Alice", email: "alice@example.com" }]); - const result = await listAssignableUsers(createContext({ - user: { findMany }, - })); + const result = await listAssignableUsers( + createContext({ + user: { findMany }, + }), + ); expect(result).toEqual([{ id: "user_1", name: "Alice", email: "alice@example.com" }]); expect(findMany).toHaveBeenCalledWith({ @@ -56,12 +58,16 @@ describe("user-procedure-support", () => { }); it("counts only users active within the trailing five minute window", async () => { - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf()); + const nowSpy = vi + .spyOn(Date, "now") + .mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf()); const count = vi.fn().mockResolvedValue(4); - const result = await countActiveUsers(createContext({ - user: { count }, - })); + const result = await countActiveUsers( + createContext({ + user: { count }, + }), + ); expect(result).toEqual({ count: 4 }); expect(count).toHaveBeenCalledWith({ @@ -80,9 +86,11 @@ describe("user-procedure-support", () => { createdAt: new Date("2026-03-30T08:00:00.000Z"), }); - const result = await getCurrentUserProfile(createContext({ - user: { findUnique }, - })); + const result = await getCurrentUserProfile( + createContext({ + user: { findUnique }, + }), + ); expect(result).toEqual({ id: "user_admin", @@ -108,17 +116,21 @@ describe("user-procedure-support", () => { it("unlinks an existing resource before linking the requested one", async () => { const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null }); - const updateMany = vi.fn() + const updateMany = vi + .fn() .mockResolvedValueOnce({ count: 1 }) .mockResolvedValueOnce({ count: 1 }); - const result = await linkUserResource(createContext({ - user: { findUnique: userFindUnique }, - resource: { findUnique: resourceFindUnique, updateMany }, - }), { - userId: "user_1", - resourceId: "resource_1", - }); + const result = await linkUserResource( + createContext({ + user: { findUnique: userFindUnique }, + resource: { findUnique: resourceFindUnique, updateMany }, + }), + { + userId: "user_1", + resourceId: "resource_1", + }, + ); expect(result).toEqual({ success: true }); expect(updateMany).toHaveBeenNthCalledWith(1, { @@ -142,9 +154,11 @@ describe("user-procedure-support", () => { updatedAt: new Date("2026-03-30T18:00:00.000Z"), }); - const result = await getDashboardLayout(createContext({ - user: { findUnique }, - })); + const result = await getDashboardLayout( + createContext({ + user: { findUnique }, + }), + ); // Widgets with unknown types normalise to empty → return null so client uses default expect(result).toEqual({ @@ -159,11 +173,14 @@ describe("user-procedure-support", () => { }); const update = vi.fn().mockResolvedValue({}); - const result = await toggleFavoriteProject(createContext({ - user: { findUnique, update }, - }), { - projectId: "project_2", - }); + const result = await toggleFavoriteProject( + createContext({ + user: { findUnique, update }, + }), + { + projectId: "project_2", + }, + ); expect(result).toEqual({ favoriteProjectIds: ["project_1", "project_2"], @@ -187,12 +204,15 @@ describe("user-procedure-support", () => { }); const update = vi.fn().mockResolvedValue({ id: "user_admin" }); - const result = await setColumnPreferences(createContext({ - user: { findUnique, update }, - }), { - view: "resources", - visible: ["name", "email"], - }); + const result = await setColumnPreferences( + createContext({ + user: { findUnique, update }, + }), + { + view: "resources", + visible: ["name", "email"], + }, + ); expect(result).toEqual({ ok: true }); expect(update).toHaveBeenCalledWith({ @@ -220,11 +240,14 @@ describe("user-procedure-support", () => { permissionOverrides: overrides, }); - const result = await getEffectiveUserPermissions(createContext({ - user: { findUnique }, - }), { - userId: "user_2", - }); + const result = await getEffectiveUserPermissions( + createContext({ + user: { findUnique }, + }), + { + userId: "user_2", + }, + ); expect(result).toEqual({ systemRole: SystemRole.MANAGER, @@ -234,14 +257,20 @@ describe("user-procedure-support", () => { }); it("reports MFA status for the current user and throws when the user no longer exists", async () => { - const findUnique = vi.fn() + const findUnique = vi + .fn() .mockResolvedValueOnce({ totpEnabled: true }) .mockResolvedValueOnce(null); + const count = vi.fn().mockResolvedValue(7); const ctx = createContext({ user: { findUnique }, + mfaBackupCode: { count }, }); - await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({ totpEnabled: true }); + await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({ + totpEnabled: true, + backupCodesRemaining: 7, + }); await expect(getCurrentMfaStatus(ctx)).rejects.toMatchObject({ code: "NOT_FOUND", message: "User not found", diff --git a/packages/api/src/__tests__/user-router.test.ts b/packages/api/src/__tests__/user-router.test.ts index 416e4f8..357262c 100644 --- a/packages/api/src/__tests__/user-router.test.ts +++ b/packages/api/src/__tests__/user-router.test.ts @@ -55,6 +55,12 @@ function createAdminCaller(db: Record) { // Individual tests can override by passing their own `activeSession` key. const dbWithDefaults = { activeSession: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + mfaBackupCode: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 10 }), + count: vi.fn().mockResolvedValue(0), + }, + $transaction: vi.fn(async (ops: unknown[]) => ops), ...db, }; return createCaller({ @@ -735,7 +741,8 @@ describe("user profile and TOTP self-service", () => { const result = await caller.verifyAndEnableTotp({ token: "123456" }); - expect(result).toEqual({ enabled: true }); + expect(result.enabled).toBe(true); + expect(result.backupCodes).toHaveLength(10); // lastTotpAt is written atomically by updateMany (the replay guard); // user.update only toggles the enabled flag after the CAS succeeds. expect(updateMany).toHaveBeenCalledWith( @@ -1035,11 +1042,16 @@ describe("user column preferences and MFA status", () => { user: { findUnique, }, + mfaBackupCode: { + deleteMany: vi.fn(), + createMany: vi.fn(), + count: vi.fn().mockResolvedValue(4), + }, }); const result = await caller.getMfaStatus(); - expect(result).toEqual({ totpEnabled: true }); + expect(result).toEqual({ totpEnabled: true, backupCodesRemaining: 4 }); expect(findUnique).toHaveBeenCalledWith({ where: { id: "user_admin" }, select: { totpEnabled: true }, diff --git a/packages/api/src/__tests__/user-self-service-mfa.test.ts b/packages/api/src/__tests__/user-self-service-mfa.test.ts index 4a251a7..511d343 100644 --- a/packages/api/src/__tests__/user-self-service-mfa.test.ts +++ b/packages/api/src/__tests__/user-self-service-mfa.test.ts @@ -61,6 +61,7 @@ import { verifyAndEnableTotp, verifyTotp, getCurrentMfaStatus, + regenerateBackupCodes, } from "../router/user-self-service-procedure-support.js"; // ─── context helpers ───────────────────────────────────────────────────────── @@ -74,10 +75,17 @@ function makeSelfServiceCtx(dbOverrides: Record = {}) { updateMany: vi.fn().mockResolvedValue({ count: 1 }), ...((dbOverrides.user as object | undefined) ?? {}), }, + mfaBackupCode: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 10 }), + count: vi.fn().mockResolvedValue(0), + ...((dbOverrides.mfaBackupCode as object | undefined) ?? {}), + }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit_1" }), ...((dbOverrides.auditLog as object | undefined) ?? {}), }, + $transaction: vi.fn(async (ops: unknown[]) => ops), }, dbUser: { id: "user_1", systemRole: "ADMIN" as const, permissionOverrides: null }, session: { @@ -145,7 +153,7 @@ describe("verifyAndEnableTotp", () => { totpEnabled: false, }; - it("enables TOTP and returns { enabled: true } when token is valid", async () => { + it("enables TOTP and returns backup codes when token is valid", async () => { totpValidateMock.mockReturnValue(0); // delta 0 = current window const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue(baseUser) }, @@ -153,7 +161,12 @@ describe("verifyAndEnableTotp", () => { const result = await verifyAndEnableTotp(ctx as Parameters[0], { token: "123456", }); - expect(result).toEqual({ enabled: true }); + expect(result.enabled).toBe(true); + expect(result.backupCodes).toHaveLength(10); + // Codes have the XXXXX-XXXXX shape (10 Crockford-base32 chars + one dash) + for (const code of result.backupCodes) { + expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/); + } expect(ctx.db.user.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: { lastTotpAt: expect.any(Date) } }), ); @@ -161,6 +174,17 @@ describe("verifyAndEnableTotp", () => { where: { id: "user_1" }, data: { totpEnabled: true }, }); + // Exactly 10 backup code rows are created in a transaction + expect(ctx.db.$transaction).toHaveBeenCalledTimes(1); + expect(ctx.db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } }); + const createCall = ctx.db.mfaBackupCode.createMany.mock.calls[0]![0] as { + data: Array<{ userId: string; codeHash: string }>; + }; + expect(createCall.data).toHaveLength(10); + for (const row of createCall.data) { + expect(row.userId).toBe("user_1"); + expect(row.codeHash.length).toBeGreaterThan(50); // argon2id encoded form + } }); it("throws BAD_REQUEST when token is invalid", async () => { @@ -314,19 +338,87 @@ describe("getCurrentMfaStatus", () => { vi.clearAllMocks(); }); - it("returns totpEnabled: true when MFA is active", async () => { + it("returns totpEnabled and backupCodesRemaining when MFA is active", async () => { const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: true }) }, + mfaBackupCode: { + count: vi.fn().mockResolvedValue(7), + deleteMany: vi.fn(), + createMany: vi.fn(), + }, }); const result = await getCurrentMfaStatus(ctx as Parameters[0]); - expect(result).toEqual({ totpEnabled: true }); + expect(result).toEqual({ totpEnabled: true, backupCodesRemaining: 7 }); }); - it("returns totpEnabled: false when MFA is inactive", async () => { + it("returns backupCodesRemaining: 0 when MFA is inactive (skips DB count)", async () => { + const countMock = vi.fn(); const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: false }) }, + mfaBackupCode: { count: countMock, deleteMany: vi.fn(), createMany: vi.fn() }, }); const result = await getCurrentMfaStatus(ctx as Parameters[0]); - expect(result).toEqual({ totpEnabled: false }); + expect(result).toEqual({ totpEnabled: false, backupCodesRemaining: 0 }); + expect(countMock).not.toHaveBeenCalled(); + }); +}); + +// ─── regenerateBackupCodes ──────────────────────────────────────────────────── + +describe("regenerateBackupCodes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws BAD_REQUEST when TOTP is not enabled", async () => { + const ctx = makeSelfServiceCtx({ + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Test User", + email: "test@example.com", + totpEnabled: false, + }), + }, + }); + await expect( + regenerateBackupCodes(ctx as Parameters[0]), + ).rejects.toThrow(TRPCError); + expect(ctx.db.$transaction).not.toHaveBeenCalled(); + }); + + it("wipes previous codes and issues a fresh set atomically", async () => { + const ctx = makeSelfServiceCtx({ + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Test User", + email: "test@example.com", + totpEnabled: true, + }), + }, + }); + const result = await regenerateBackupCodes(ctx as Parameters[0]); + expect(result.count).toBe(10); + expect(result.codes).toHaveLength(10); + expect(new Set(result.codes).size).toBe(10); // all distinct + expect(ctx.db.$transaction).toHaveBeenCalledTimes(1); + expect(ctx.db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } }); + }); + + it("writes an audit entry on regeneration", async () => { + const ctx = makeSelfServiceCtx({ + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Test User", + email: "test@example.com", + totpEnabled: true, + }), + }, + }); + await regenerateBackupCodes(ctx as Parameters[0]); + await new Promise((r) => setTimeout(r, 0)); + expect(ctx.db.auditLog.create).toHaveBeenCalled(); }); }); diff --git a/packages/api/src/lib/mfa-backup-code-redeem.ts b/packages/api/src/lib/mfa-backup-code-redeem.ts new file mode 100644 index 0000000..ef6e93f --- /dev/null +++ b/packages/api/src/lib/mfa-backup-code-redeem.ts @@ -0,0 +1,74 @@ +import { verifyBackupCode } from "./mfa-backup-codes.js"; + +// Redeem a backup code atomically. The flow is: +// +// 1. Load all still-redeemable rows (usedAt IS NULL) for the user. +// 2. Linear-scan with argon2 verify until one matches. Hashes are +// expensive by design — 10 candidates max is fine, and the cost is +// the user's own memory-hard-hash budget, not an attacker-chosen one. +// 3. The matching row is deleted under a WHERE-guard on (id, usedAt IS +// NULL). Count=0 means another request consumed the same code first +// (replay race); the caller treats it as an invalid code. +// +// Deleting (vs marking `usedAt`) keeps the table small and makes post- +// compromise forensics simpler — a used code is an absence, not a +// still-present-but-tombstoned row that could be reactivated via SQL +// injection or bad migration. +// +// Returned `remaining` lets the UI warn "3 backup codes left — generate +// more" without a second round-trip. + +interface BackupCodeRow { + id: string; + codeHash: string; +} + +interface RedeemDb { + mfaBackupCode: { + findMany: (args: { + where: { userId: string; usedAt: null }; + select: { id: true; codeHash: true }; + }) => Promise; + deleteMany: (args: { where: { id: string; usedAt: null } }) => Promise<{ count: number }>; + count: (args: { where: { userId: string; usedAt: null } }) => Promise; + }; +} + +export interface RedeemResult { + accepted: boolean; + remaining: number; +} + +export async function redeemBackupCode( + db: { mfaBackupCode: unknown }, + userId: string, + plaintext: string, +): Promise { + const typed = db as unknown as RedeemDb; + + const rows = await typed.mfaBackupCode.findMany({ + where: { userId, usedAt: null }, + select: { id: true, codeHash: true }, + }); + + for (const row of rows) { + if (!(await verifyBackupCode(row.codeHash, plaintext))) continue; + + const del = await typed.mfaBackupCode.deleteMany({ + where: { id: row.id, usedAt: null }, + }); + if (del.count === 0) { + // Raced — another request consumed this same code. Treat as invalid + // so the attacker cannot learn it was valid; an honest user retries + // with a fresh code. + return { + accepted: false, + remaining: await typed.mfaBackupCode.count({ where: { userId, usedAt: null } }), + }; + } + const remaining = await typed.mfaBackupCode.count({ where: { userId, usedAt: null } }); + return { accepted: true, remaining }; + } + + return { accepted: false, remaining: rows.length }; +} diff --git a/packages/api/src/lib/mfa-backup-codes.ts b/packages/api/src/lib/mfa-backup-codes.ts new file mode 100644 index 0000000..9bc2a49 --- /dev/null +++ b/packages/api/src/lib/mfa-backup-codes.ts @@ -0,0 +1,55 @@ +import { randomBytes } from "node:crypto"; +import { hash, verify } from "@node-rs/argon2"; + +// Backup codes are the last-resort credential when a user loses their TOTP +// device. Design constraints: +// +// 1. High entropy but human-typeable. 10 chars of Crockford-base32 = +// 50 bits — well above the 20-bit floor that brute-force-proofs the +// 6 codes/15 min rate limit (2^20 / (6/900) ≈ 5000 years average). +// 2. Never logged or stored in plaintext. We hash with argon2id (same +// hasher as passwords) and delete the row on redemption, so replay is +// physically impossible even if the DB leaks post-redemption. +// 3. One-shot visibility. Plaintext is returned exactly once from the +// generate mutation — re-display is not supported; lost codes must be +// regenerated, which invalidates the full set. +// +// The formatted shape (XXXXX-XXXXX) is cosmetic only; validation strips the +// dash so users can paste either form. + +export const BACKUP_CODE_COUNT = 10; +const CODE_LENGTH = 10; // chars, pre-dash +// Crockford base32 alphabet: no 0/O/1/I/L to avoid transcription errors. +const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +export function generatePlaintextBackupCodes(count: number = BACKUP_CODE_COUNT): string[] { + const codes: string[] = []; + for (let i = 0; i < count; i++) { + const bytes = randomBytes(CODE_LENGTH); + let code = ""; + for (let j = 0; j < CODE_LENGTH; j++) { + code += ALPHABET[bytes[j]! % ALPHABET.length]; + } + codes.push(`${code.slice(0, 5)}-${code.slice(5)}`); + } + return codes; +} + +// Users may paste the code with or without the dash, and in either case; +// store and compare the canonical form (uppercase, no dash, no whitespace) +// so accidental formatting does not reject an otherwise-valid code. +export function normalizeBackupCode(input: string): string { + return input.replace(/[\s-]+/g, "").toUpperCase(); +} + +export async function hashBackupCode(plaintext: string): Promise { + return hash(normalizeBackupCode(plaintext)); +} + +export async function verifyBackupCode(codeHash: string, plaintext: string): Promise { + try { + return await verify(codeHash, normalizeBackupCode(plaintext)); + } catch { + return false; + } +} diff --git a/packages/api/src/router/user-self-service-procedure-support.ts b/packages/api/src/router/user-self-service-procedure-support.ts index ab43a44..bb70739 100644 --- a/packages/api/src/router/user-self-service-procedure-support.ts +++ b/packages/api/src/router/user-self-service-procedure-support.ts @@ -5,6 +5,11 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; +import { + BACKUP_CODE_COUNT, + generatePlaintextBackupCodes, + hashBackupCode, +} from "../lib/mfa-backup-codes.js"; import { consumeTotpWindow } from "../lib/totp-consume.js"; import { totpRateLimiter } from "../middleware/rate-limit.js"; import type { TRPCContext } from "../trpc.js"; @@ -251,6 +256,21 @@ export async function verifyAndEnableTotp( data: { totpEnabled: true }, }); + // Issue the initial backup-code set as part of the enable flow. Doing + // this here (vs making it a separate opt-in step) avoids the common + // footgun of users enabling MFA, losing their device, and being locked + // out — one of the explicit motivations for #43 part 2. + const plaintexts = generatePlaintextBackupCodes(BACKUP_CODE_COUNT); + const hashes = await Promise.all(plaintexts.map((p) => hashBackupCode(p))); + await ctx.db.$transaction([ + (ctx.db as unknown as { mfaBackupCode: { deleteMany: Function } }).mfaBackupCode.deleteMany({ + where: { userId: user.id }, + }), + (ctx.db as unknown as { mfaBackupCode: { createMany: Function } }).mfaBackupCode.createMany({ + data: hashes.map((codeHash) => ({ userId: user.id, codeHash })), + }), + ]); + void createAuditEntry({ db: ctx.db, entityType: "User", @@ -262,7 +282,7 @@ export async function verifyAndEnableTotp( summary: "Enabled TOTP MFA", }); - return { enabled: true }; + return { enabled: true, backupCodes: plaintexts }; } export async function verifyTotp( @@ -330,5 +350,70 @@ export async function getCurrentMfaStatus(ctx: UserSelfServiceContext) { "User", ); - return { totpEnabled: user.totpEnabled }; + const backupCodesRemaining = user.totpEnabled + ? await ( + ctx.db as unknown as { + mfaBackupCode: { + count: (args: { where: { userId: string; usedAt: null } }) => Promise; + }; + } + ).mfaBackupCode.count({ + where: { userId: ctx.dbUser!.id, usedAt: null }, + }) + : 0; + + return { totpEnabled: user.totpEnabled, backupCodesRemaining }; +} + +// Generate (or regenerate) a user's backup-code set. Returns the plaintext +// codes exactly once — the caller MUST display them immediately; there is +// no re-display endpoint. Regeneration wipes the previous set atomically +// (deleteMany + createMany in a transaction), so a partially-regenerated +// state — some old codes still valid, some new codes issued — is not +// observable to either the user or an attacker. +// +// Requires TOTP to already be enabled: the codes are a *backup* for an +// existing second factor, not a way to bootstrap MFA. +export async function regenerateBackupCodes(ctx: UserSelfServiceContext) { + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: ctx.dbUser!.id }, + select: { id: true, name: true, email: true, totpEnabled: true }, + }), + "User", + ); + if (!user.totpEnabled) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Enable TOTP before generating backup codes.", + }); + } + + const plaintexts = generatePlaintextBackupCodes(BACKUP_CODE_COUNT); + const hashes = await Promise.all(plaintexts.map((p) => hashBackupCode(p))); + + // Transaction guarantees all-or-nothing replacement: a failure after + // deleteMany but before createMany would otherwise leave the user with + // zero backup codes and a UI that thinks they have 10. + await ctx.db.$transaction([ + (ctx.db as unknown as { mfaBackupCode: { deleteMany: Function } }).mfaBackupCode.deleteMany({ + where: { userId: user.id }, + }), + (ctx.db as unknown as { mfaBackupCode: { createMany: Function } }).mfaBackupCode.createMany({ + data: hashes.map((codeHash) => ({ userId: user.id, codeHash })), + }), + ]); + + void createAuditEntry({ + db: ctx.db, + entityType: "User", + entityId: user.id, + entityName: `${user.name} (${user.email})`, + action: "UPDATE", + userId: user.id, + source: "ui", + summary: "Regenerated MFA backup codes", + }); + + return { codes: plaintexts, count: plaintexts.length }; } diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 564c118..be7eaf3 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -42,6 +42,7 @@ import { saveDashboardLayout, SetColumnPreferencesInputSchema, setColumnPreferences, + regenerateBackupCodes, ToggleFavoriteProjectInputSchema, toggleFavoriteProject, verifyAndEnableTotp as verifyAndEnableTotpSelfService, @@ -152,4 +153,7 @@ export const userRouter = createTRPCRouter({ /** Get MFA status for the current user. */ getMfaStatus: protectedProcedure.query(({ ctx }) => getCurrentMfaStatus(ctx)), + + /** Generate a fresh set of MFA backup codes, invalidating any previous set. */ + regenerateBackupCodes: protectedProcedure.mutation(({ ctx }) => regenerateBackupCodes(ctx)), }); diff --git a/packages/db/prisma/migrations/20260417_mfa_backup_codes.sql b/packages/db/prisma/migrations/20260417_mfa_backup_codes.sql new file mode 100644 index 0000000..c46a4f2 --- /dev/null +++ b/packages/db/prisma/migrations/20260417_mfa_backup_codes.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "mfa_backup_codes" ( + "id" TEXT PRIMARY KEY, + "userId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "mfa_backup_codes_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "mfa_backup_codes_userId_idx" + ON "mfa_backup_codes"("userId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 84dc438..929b41e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -205,6 +205,7 @@ model User { activeSessions ActiveSession[] reportTemplates ReportTemplate[] assistantApprovals AssistantApproval[] + mfaBackupCodes MfaBackupCode[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -212,6 +213,24 @@ model User { @@map("users") } +// One row per still-redeemable backup code. We store argon2id(code) — never +// the plaintext — and delete the row on redemption so replay is physically +// impossible. Generation wipes and recreates the whole set (kick-oldest +// strategy not used here: recovery codes are all-or-nothing, a partial +// set is worse than none). +model MfaBackupCode { + id String @id @default(cuid()) + userId String + codeHash String + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("mfa_backup_codes") +} + enum AssistantApprovalStatus { PENDING APPROVED