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,22 +222,69 @@ 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
<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}
|
||||
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
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,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 (
|
||||
<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">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<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">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">
|
||||
MFA Enabled
|
||||
</h3>
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
Two-factor authentication is active on your account.
|
||||
</p>
|
||||
</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"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-green-800 dark:text-green-300">
|
||||
MFA Enabled
|
||||
</h3>
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
Two-factor authentication is active on your account.
|
||||
</p>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
+80
-39
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user