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:
+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