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

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:
2026-04-17 18:47:18 +02:00
parent 9dc1ffd3ad
commit fe79810a85
16 changed files with 890 additions and 136 deletions
+71 -11
View File
@@ -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>