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>
|
||||
|
||||
Reference in New Issue
Block a user