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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user