security: MFA backup codes — issue on enable, redeem at login, regenerate on demand (#43)
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
Adds a one-time-use backup code set so users with a lost authenticator are not locked out. Codes are Crockford base32 (XXXXX-XXXXX), hashed with argon2id, and redeemed under a WHERE-guarded delete so a concurrent replay race fails closed. - New MfaBackupCode model + migration - Issue 10 codes inside the enable transaction; show plaintext exactly once - Sign-in page accepts TOTP or backup code, reporting remaining count - regenerateBackupCodes tRPC mutation wipes + reissues atomically - Unit coverage for generator, normalizer, verify, redeem, and race path Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HTMLInputElement>(null);
|
||||
const backupCodeInputRef = useRef<HTMLInputElement>(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 && (
|
||||
<div>
|
||||
<label htmlFor="totp" className="app-label">
|
||||
Verification Code
|
||||
@@ -209,15 +222,61 @@ export default function SignInPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mfaRequired && useBackupCode && (
|
||||
<div>
|
||||
<label htmlFor="backup-code" className="app-label">
|
||||
Backup Code
|
||||
</label>
|
||||
<input
|
||||
ref={backupCodeInputRef}
|
||||
id="backup-code"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={16}
|
||||
value={backupCode}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Each backup code works once. You'll need to regenerate your codes after using
|
||||
one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (mfaRequired && totp.length !== 6)}
|
||||
disabled={
|
||||
loading ||
|
||||
(mfaRequired && !useBackupCode && totp.length !== 6) ||
|
||||
(mfaRequired && useBackupCode && backupCode.replace(/[\s-]/g, "").length < 8)
|
||||
}
|
||||
className="w-full rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-600/25 transition-colors hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Signing in..." : mfaRequired ? "Verify" : "Sign in"}
|
||||
</button>
|
||||
|
||||
{mfaRequired && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseBackupCode((v) => !v);
|
||||
setError("");
|
||||
setTotp("");
|
||||
setBackupCode("");
|
||||
setTimeout(() => {
|
||||
if (useBackupCode) totpInputRef.current?.focus();
|
||||
else backupCodeInputRef.current?.focus();
|
||||
}, 100);
|
||||
}}
|
||||
className="w-full text-center text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
{useBackupCode ? "Use authenticator code instead" : "Use a backup code instead"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
@@ -225,6 +284,7 @@ export default function SignInPage() {
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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<SetupStep>("idle");
|
||||
@@ -12,6 +12,7 @@ export function MfaSetup() {
|
||||
const [uri, setUri] = useState("");
|
||||
const [qrDataUrl, setQrDataUrl] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(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,8 +63,50 @@ 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40">
|
||||
@@ -90,6 +134,42 @@ export function MfaSetup() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-6 ${
|
||||
lowCodes
|
||||
? "border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20"
|
||||
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Backup codes
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{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 && <span className="font-medium">Regenerate soon.</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
disabled={regenerateBackupCodesMutation.isPending}
|
||||
className="shrink-0 inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{regenerateBackupCodesMutation.isPending ? "Generating…" : "Regenerate codes"}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-2 text-sm text-red-700 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,6 +330,53 @@ export function MfaSetup() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "show-backup-codes" && backupCodes && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
||||
Save your backup codes
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-white dark:bg-gray-900 p-4 font-mono text-sm">
|
||||
{backupCodes.map((code) => (
|
||||
<code
|
||||
key={code}
|
||||
className="rounded bg-gray-100 dark:bg-gray-800 px-3 py-2 text-center tracking-wider select-all"
|
||||
>
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyBackupCodes}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Copy all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadBackupCodes}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Download .txt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFinishBackupCodes}
|
||||
className="ml-auto inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700"
|
||||
>
|
||||
I've saved them
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,13 +161,48 @@ 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();
|
||||
}
|
||||
|
||||
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: "UPDATE",
|
||||
userId: user.id,
|
||||
summary: `Backup code redeemed (${result.remaining} remaining)`,
|
||||
source: "ui",
|
||||
});
|
||||
// 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",
|
||||
@@ -173,7 +213,7 @@ const config = {
|
||||
secret: Secret.fromBase32(user.totpSecret),
|
||||
});
|
||||
|
||||
const delta = totpInstance.validate({ token: totp, window: 1 });
|
||||
const delta = totpInstance.validate({ token: totp!, window: 1 });
|
||||
if (delta === null) {
|
||||
logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification");
|
||||
await createAuditEntry({
|
||||
@@ -209,6 +249,7 @@ const config = {
|
||||
throw new InvalidTotpError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MFA enforcement: if the user's role is in requireMfaForRoles but they
|
||||
// haven't set up TOTP yet, block login and signal setup requirement.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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"],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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",
|
||||
|
||||
@@ -55,6 +55,12 @@ function createAdminCaller(db: Record<string, unknown>) {
|
||||
// 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 },
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<typeof verifyAndEnableTotp>[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<typeof getCurrentMfaStatus>[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<typeof getCurrentMfaStatus>[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<typeof regenerateBackupCodes>[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<typeof regenerateBackupCodes>[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<typeof regenerateBackupCodes>[0]);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(ctx.db.auditLog.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BackupCodeRow[]>;
|
||||
deleteMany: (args: { where: { id: string; usedAt: null } }) => Promise<{ count: number }>;
|
||||
count: (args: { where: { userId: string; usedAt: null } }) => Promise<number>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RedeemResult {
|
||||
accepted: boolean;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export async function redeemBackupCode(
|
||||
db: { mfaBackupCode: unknown },
|
||||
userId: string,
|
||||
plaintext: string,
|
||||
): Promise<RedeemResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string> {
|
||||
return hash(normalizeBackupCode(plaintext));
|
||||
}
|
||||
|
||||
export async function verifyBackupCode(codeHash: string, plaintext: string): Promise<boolean> {
|
||||
try {
|
||||
return await verify(codeHash, normalizeBackupCode(plaintext));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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<number>;
|
||||
};
|
||||
}
|
||||
).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 };
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user