feat(auth): add TOTP second factor and recovery codes

Add the TOTP enroll/confirm/disable surface and Argon2id-hashed recovery
codes as the v1.23 second factor.

- totp.ts wraps otpauth (zero-dep) pinned to the interop floor (SHA-1, 6
  digits, 30 s) with a +/-1 drift window. Replay protection persists the last
  accepted time-step per account (User.totpLastStep, migration
  0202_v123_totp_laststep) and rejects any step that is not strictly newer,
  so a captured code dies the instant it is accepted once.
- recovery-codes.ts issues ten single-use, high-entropy codes, Argon2id-
  hashed at rest, shown once; regeneration replaces the entire prior set in a
  transaction and verification burns the matched code with a guarded update.
- The TOTP secret is stored AES-256-GCM encrypted (already registered in the
  encrypted-columns registry + rotation script); decrypt is fail-closed.
- Routes: setup (pending secret), confirm (activate + issue recovery codes),
  disable (step-up + current factor), recovery-codes/regenerate (step-up).
  Setup/confirm are cookie-only; disable/regenerate add requireFreshMfa.
- scripts/disable-mfa.ts is the operator escape hatch for a locked-out
  self-hoster, audited, run via pnpm dlx tsx.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 19:56:16 +02:00
1 parent 3d3018b12e
commit b446a66753
15 files changed
+1086

No files matched your search

+1
View File
@@ -74,6 +74,7 @@
"next": "16.2.9",
"next-themes": "^0.4.6",
"nodemailer": "^9.0.1",
"otpauth": "^9.5.1",
"p-limit": "^7.3.0",
"pg-boss": "^12.20.0",
"prisma": "^7.8.0",
+16
View File
@@ -84,6 +84,9 @@ importers:
nodemailer:
specifier: ^9.0.1
version: 9.0.1
otpauth:
specifier: ^9.5.1
version: 9.5.1
p-limit:
specifier: ^7.3.0
version: 7.3.0
@@ -1290,6 +1293,10 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@nodable/entities@2.1.0':
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
@@ -5396,6 +5403,9 @@ packages:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
otpauth@9.5.1:
resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==}
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
@@ -7978,6 +7988,8 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@noble/hashes@2.2.0': {}
'@nodable/entities@2.1.0': {}
'@node-rs/argon2-android-arm-eabi@2.0.2':
@@ -12157,6 +12169,10 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
otpauth@9.5.1:
dependencies:
'@noble/hashes': 2.2.0
outvariant@1.4.3: {}
own-keys@1.0.1:
@@ -0,0 +1,11 @@
-- v1.23 — TOTP replay guard.
--
-- Persists the last accepted time-step counter per account so a verified
-- code cannot be replayed within its 30-second validity window. The verify
-- path resolves the submitted code to its RFC 6238 step (`floor(now /
-- period)` adjusted by the ±1 drift delta) and rejects any step that is not
-- strictly greater than this column. Additive, nullable — existing rows
-- (no TOTP enrolled) need no backfill, and a freshly confirmed factor starts
-- with NULL (the first verify simply records its step).
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "totp_last_step" BIGINT;
+6
View File
@@ -556,6 +556,12 @@ model User {
// `passkeyUpgradeNudgeDismissed` are per-account flags later waves read.
totpSecretEncrypted String? @map("totp_secret_encrypted")
totpConfirmedAt DateTime? @map("totp_confirmed_at")
// v1.23 — TOTP replay guard. The last accepted time-step counter
// (RFC 6238 `T = floor(now / period)`). A code is rejected when its
// resolved step is <= this value even while still inside the ±1 drift
// window, so a captured code cannot be reused within its 30-second
// life. BigInt because the step counter grows unbounded with wall time.
totpLastStep BigInt? @map("totp_last_step")
mfaEnforced Boolean @default(false) @map("mfa_enforced")
passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed")
+90
View File
@@ -0,0 +1,90 @@
/**
* scripts/disable-mfa.ts <email-or-username>
*
* Operator escape hatch: clear the second factor for an account whose owner
* has locked themselves out (lost authenticator AND recovery codes). Mirrors
* the password-reset CLI — it runs out-of-band against the database, so it is
* the recovery path of last resort for a self-hoster.
*
* It clears the TOTP secret, the confirmed-at stamp and the replay counter,
* and deletes every recovery code, then writes an `auth.mfa.disabled` audit
* row (with `details.source = "cli"`) so the action is traceable.
*
* Run via the bundled tsx (the production standalone image strips tsx):
* pnpm dlx tsx scripts/disable-mfa.ts user@example.com
*/
import "dotenv/config";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("DATABASE_URL must be set");
process.exit(1);
}
const identifier = process.argv[2];
if (!identifier) {
console.error(
"Usage: pnpm dlx tsx scripts/disable-mfa.ts <email-or-username>",
);
process.exit(2);
}
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
});
async function main(): Promise<void> {
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: { equals: identifier, mode: "insensitive" } },
{ username: { equals: identifier, mode: "insensitive" } },
],
},
select: { id: true, username: true, totpConfirmedAt: true },
});
if (!user) {
console.error(`No account matches '${identifier}'.`);
process.exit(3);
}
if (!user.totpConfirmedAt) {
console.log(
`Account '${user.username}' has no active second factor — nothing to do.`,
);
return;
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: {
totpSecretEncrypted: null,
totpConfirmedAt: null,
totpLastStep: null,
},
});
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
await tx.auditLog.create({
data: {
action: "auth.mfa.disabled",
userId: user.id,
details: JSON.stringify({ source: "cli", factor: "totp" }),
},
});
});
console.log(`Second factor cleared for account '${user.username}'.`);
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(() => {
void prisma.$disconnect();
});
+100
View File
@@ -0,0 +1,100 @@
/**
* POST /api/auth/me/mfa/disable
*
* Tear down the second factor. Double-gated:
* 1. `requireFreshMfa` — a cookie session that completed a second factor
* within the step-up window (Bearer can never satisfy this).
* 2. A current TOTP or recovery code in the body — proves live possession at
* the moment of the destructive action, not just a stale fresh-session.
*
* On success it clears `totpSecretEncrypted` / `totpConfirmedAt` /
* `totpLastStep` and deletes every recovery code in one transaction.
*/
import {
apiHandler,
requireFreshMfa,
HttpError,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { mfaDisableSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
const DISABLE_RATE_LIMIT = 5;
const DISABLE_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
// session is not freshly second-factor-verified.
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
const rl = await checkRateLimit(
`mfa:disable:${user.id}`,
DISABLE_RATE_LIMIT,
DISABLE_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(400, "Invalid JSON body");
}
const parsed = mfaDisableSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const factor = await verifyMfaFactor(
user,
parsed.data.method,
parsed.data.code,
);
if (!factor.ok) {
await auditLog("auth.mfa.failed", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
stage: "disable",
method: parsed.data.method,
replay: factor.replay,
},
});
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
throw new HttpError(401, "Invalid code");
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: {
totpSecretEncrypted: null,
totpConfirmedAt: null,
totpLastStep: null,
},
});
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
});
await auditLog("auth.mfa.disabled", {
userId: user.id,
ipAddress: getClientIp(req),
details: { factor: parsed.data.method },
});
annotate({ action: { name: "auth.mfa.disabled" } });
return apiSuccess({ enabled: false });
});
@@ -0,0 +1,60 @@
/**
* POST /api/auth/me/mfa/recovery-codes/regenerate
*
* Issue a fresh recovery-code batch, invalidating the entire prior set in one
* transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is
* a sensitive action; Bearer can never satisfy the gate. The new codes are
* returned **once**.
*/
import {
apiHandler,
requireFreshMfa,
HttpError,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { regenerateRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
export const dynamic = "force-dynamic";
const REGEN_RATE_LIMIT = 5;
const REGEN_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
// Defence in depth — regeneration only makes sense for an active factor.
if (!user.totpConfirmedAt) {
throw new HttpError(409, "No second factor is active");
}
const rl = await checkRateLimit(
`mfa:recovery-regen:${user.id}`,
REGEN_RATE_LIMIT,
REGEN_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
const recoveryCodes = await regenerateRecoveryCodes(user.id);
await auditLog("auth.mfa.recovery_regenerated", {
userId: user.id,
ipAddress: getClientIp(req),
details: { count: recoveryCodes.length },
});
annotate({ action: { name: "auth.mfa.recovery_regenerated" } });
return apiSuccess({
recoveryCodes,
recoveryCodesRemaining: recoveryCodes.length,
});
});
@@ -0,0 +1,124 @@
/**
* POST /api/auth/me/mfa/totp/confirm
*
* Finish TOTP enrollment. Verifies a code against the pending secret; on
* success it promotes pending → active (`totpConfirmedAt = now()`), records
* the accepted time-step (replay guard), generates + persists the Argon2id-
* hashed recovery codes, and returns the codes **once** for the user to save.
*
* The completing session is also stamped `mfaVerifiedAt = now()` so the user
* can immediately manage the freshly-enabled factor (disable / regenerate
* codes) without an extra step-up round-trip.
*
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA.
*/
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyTotp } from "@/lib/auth/mfa/totp";
import {
generateRecoveryCodes,
persistRecoveryCodes,
} from "@/lib/auth/mfa/recovery-codes";
import { totpConfirmSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
const CONFIRM_RATE_LIMIT = 10;
const CONFIRM_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
const { user, session } = await requireCookieAuth();
const rl = await checkRateLimit(
`mfa:confirm:${user.id}`,
CONFIRM_RATE_LIMIT,
CONFIRM_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
if (user.totpConfirmedAt) {
throw new HttpError(409, "A second factor is already active");
}
if (!user.totpSecretEncrypted) {
throw new HttpError(409, "Start enrollment before confirming");
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(400, "Invalid JSON body");
}
const parsed = totpConfirmSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid code", 422);
}
// Decrypt is fail-closed — a missing/rotated key throws rather than
// confirming against junk.
const secret = decrypt(user.totpSecretEncrypted);
const result = verifyTotp(
secret,
parsed.data.code,
user.totpLastStep === null ? null : Number(user.totpLastStep),
);
if (!result.valid) {
await auditLog("auth.mfa.failed", {
userId: user.id,
ipAddress: getClientIp(req),
details: { stage: "confirm", replay: result.replay },
});
annotate({
action: { name: "auth.mfa.totp.confirm.invalid" },
meta: { replay: result.replay },
});
throw new HttpError(401, "Invalid code");
}
const recoveryCodes = generateRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: {
totpConfirmedAt: new Date(),
totpLastStep: BigInt(result.step as number),
},
});
// A fresh enrollment starts from a clean recovery set.
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
await persistRecoveryCodes(tx, user.id, recoveryCodes);
// Stamp the completing session so the user can immediately manage the
// factor under step-up without re-verifying.
await tx.session.updateMany({
where: { id: session.id },
data: { mfaVerifiedAt: new Date() },
});
});
await auditLog("auth.mfa.enabled", {
userId: user.id,
ipAddress: getClientIp(req),
details: { factor: "totp" },
});
annotate({ action: { name: "auth.mfa.enabled" } });
// `recoveryCodes` is redaction-denylisted (`/recovery.?code/i`).
return apiSuccess({
enabled: true,
recoveryCodes,
recoveryCodesRemaining: recoveryCodes.length,
});
});
@@ -0,0 +1,76 @@
/**
* POST /api/auth/me/mfa/totp/setup
*
* Begin TOTP enrollment. Generates a 160-bit secret, stores it **encrypted**
* (AES-256-GCM, the same at-rest envelope as every other credential), and
* returns the `otpauth://` URI + the raw Base32 secret so the client can
* render the QR and offer manual entry. The secret is **pending** — MFA is
* not active until `/confirm` verifies a code, so `totpConfirmedAt` stays
* null here.
*
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The
* recovery-code batch is issued at `/confirm` (after the factor is proven),
* not here, so an abandoned setup never persists codes.
*/
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
export const dynamic = "force-dynamic";
const SETUP_RATE_LIMIT = 5;
const SETUP_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireCookieAuth();
const rl = await checkRateLimit(
`mfa:setup:${user.id}`,
SETUP_RATE_LIMIT,
SETUP_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
// An already-active factor must be disabled (step-up gated) before a new
// secret can be enrolled — re-running setup must not silently rotate the
// live secret out from under the user's authenticator.
if (user.totpConfirmedAt) {
annotate({ action: { name: "auth.mfa.totp.setup.already_active" } });
throw new HttpError(409, "A second factor is already active");
}
const secret = generateTotpSecret();
const account = user.email ?? user.username;
const otpauthUri = buildOtpauthUri(secret, account);
// Store the pending secret encrypted; leave `totpConfirmedAt` null.
await prisma.user.update({
where: { id: user.id },
data: {
totpSecretEncrypted: encrypt(secret),
// A re-setup resets the replay counter — the new secret starts fresh.
totpLastStep: null,
},
});
await auditLog("auth.mfa.totp.setup", {
userId: user.id,
ipAddress: getClientIp(req),
});
annotate({ action: { name: "auth.mfa.totp.setup" } });
// `totpSecret` / `otpauthUri` are redaction-denylisted (`/totp/i`,
// `/otp/i`, `/secret/i`) so they never surface in a wide-event excerpt.
return apiSuccess({ otpauthUri, totpSecret: secret });
});
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Real Argon2id hashing (a handful of codes is fast enough for a unit test)
// so the hash/verify round-trip is exercised end-to-end. Only the DB is mocked.
vi.mock("@/lib/db", () => ({
prisma: {
mfaRecoveryCode: {
createMany: vi.fn(),
deleteMany: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
count: vi.fn(),
},
$transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => {
// The transactional helper receives the same mocked client.
const { prisma } = await import("@/lib/db");
return fn(prisma);
}),
},
}));
import {
generateRecoveryCodes,
regenerateRecoveryCodes,
verifyAndConsumeRecoveryCode,
normaliseRecoveryCode,
RECOVERY_CODE_COUNT,
} from "../recovery-codes";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/db";
beforeEach(() => {
vi.clearAllMocks();
});
describe("recovery-codes", () => {
it("generates the configured count of high-entropy, grouped codes", () => {
const codes = generateRecoveryCodes();
expect(codes).toHaveLength(RECOVERY_CODE_COUNT);
for (const c of codes) {
expect(c).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);
}
// No duplicates in a batch.
expect(new Set(codes).size).toBe(codes.length);
});
it("verifies a code once then refuses it (one-time use)", async () => {
const code = "ABCDE-FGHJK";
const codeHash = await hashPassword(normaliseRecoveryCode(code));
vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([
{ id: "row-1", codeHash },
] as never);
// First consume succeeds — the guarded update flips exactly one row.
vi.mocked(prisma.mfaRecoveryCode.updateMany).mockResolvedValueOnce({
count: 1,
} as never);
const first = await verifyAndConsumeRecoveryCode("user-1", code);
expect(first).toBe(true);
// Second attempt: the row is now used, so findMany returns nothing.
vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([] as never);
const second = await verifyAndConsumeRecoveryCode("user-1", code);
expect(second).toBe(false);
});
it("treats a lost claim race (count !== 1) as a miss", async () => {
const code = "ABCDE-FGHJK";
const codeHash = await hashPassword(normaliseRecoveryCode(code));
vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([
{ id: "row-1", codeHash },
] as never);
vi.mocked(prisma.mfaRecoveryCode.updateMany).mockResolvedValueOnce({
count: 0,
} as never);
expect(await verifyAndConsumeRecoveryCode("user-1", code)).toBe(false);
});
it("rejects a code that matches no stored hash", async () => {
const realHash = await hashPassword(normaliseRecoveryCode("AAAAA-BBBBB"));
vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([
{ id: "row-1", codeHash: realHash },
] as never);
expect(await verifyAndConsumeRecoveryCode("user-1", "ZZZZZ-99999")).toBe(
false,
);
expect(prisma.mfaRecoveryCode.updateMany).not.toHaveBeenCalled();
});
it("regeneration deletes the entire prior set before inserting", async () => {
vi.mocked(prisma.mfaRecoveryCode.deleteMany).mockResolvedValue({
count: 10,
} as never);
vi.mocked(prisma.mfaRecoveryCode.createMany).mockResolvedValue({
count: 10,
} as never);
const codes = await regenerateRecoveryCodes("user-1");
expect(codes).toHaveLength(RECOVERY_CODE_COUNT);
expect(prisma.mfaRecoveryCode.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
// Delete is ordered before the insert.
const deleteOrder = vi.mocked(prisma.mfaRecoveryCode.deleteMany).mock
.invocationCallOrder[0];
const insertOrder = vi.mocked(prisma.mfaRecoveryCode.createMany).mock
.invocationCallOrder[0];
expect(deleteOrder).toBeLessThan(insertOrder);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import * as OTPAuth from "otpauth";
import {
generateTotpSecret,
buildOtpauthUri,
verifyTotp,
currentTotpStep,
TOTP_PERIOD_SECONDS,
} from "../totp";
/** Compute the canonical code a compliant authenticator would show at `atMs`. */
function codeAt(secretBase32: string, atMs: number): string {
const totp = new OTPAuth.TOTP({
issuer: "HealthLog",
label: "HealthLog",
algorithm: "SHA1",
digits: 6,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
});
return totp.generate({ timestamp: atMs });
}
const PERIOD_MS = TOTP_PERIOD_SECONDS * 1000;
describe("totp", () => {
it("generates a Base32 secret and a parseable otpauth URI", () => {
const secret = generateTotpSecret();
expect(secret).toMatch(/^[A-Z2-7]+$/);
const uri = buildOtpauthUri(secret, "user@example.com");
expect(uri.startsWith("otpauth://totp/")).toBe(true);
expect(uri).toContain("issuer=HealthLog");
expect(uri).toContain("algorithm=SHA1");
expect(uri).toContain("digits=6");
// Round-trips back to the same secret.
const parsed = OTPAuth.URI.parse(uri) as OTPAuth.TOTP;
expect(parsed.secret.base32).toBe(secret);
});
it("accepts the current code and reports its step", () => {
const secret = generateTotpSecret();
const now = 1_700_000_000_000;
const res = verifyTotp(secret, codeAt(secret, now), null, now);
expect(res.valid).toBe(true);
expect(res.replay).toBe(false);
expect(res.step).toBe(currentTotpStep(now));
});
it("rejects a wrong code and a non-numeric code", () => {
const secret = generateTotpSecret();
const now = 1_700_000_000_000;
expect(verifyTotp(secret, "000000", null, now).valid).toBe(false);
expect(verifyTotp(secret, "abcdef", null, now).valid).toBe(false);
expect(verifyTotp(secret, "12345", null, now).valid).toBe(false); // too short
});
it("accepts the previous and next step within the ±1 drift window", () => {
const secret = generateTotpSecret();
const now = 1_700_000_400_000;
const prev = codeAt(secret, now - PERIOD_MS);
const next = codeAt(secret, now + PERIOD_MS);
expect(verifyTotp(secret, prev, null, now).valid).toBe(true);
expect(verifyTotp(secret, next, null, now).valid).toBe(true);
});
it("rejects a code two steps away (outside the window)", () => {
const secret = generateTotpSecret();
const now = 1_700_000_400_000;
const twoBack = codeAt(secret, now - 2 * PERIOD_MS);
expect(verifyTotp(secret, twoBack, null, now).valid).toBe(false);
});
it("rejects replay of an already-accepted step (monotonic guard)", () => {
const secret = generateTotpSecret();
const now = 1_700_000_000_000;
const code = codeAt(secret, now);
const first = verifyTotp(secret, code, null, now);
expect(first.valid).toBe(true);
const step = first.step as number;
// Same code, now with lastStep recorded — must be rejected as a replay
// even though it is still inside its 30-second life.
const second = verifyTotp(secret, code, step, now);
expect(second.valid).toBe(false);
expect(second.replay).toBe(true);
expect(second.step).toBe(step);
});
it("rejects a still-valid older step once a newer one was accepted", () => {
const secret = generateTotpSecret();
const now = 1_700_000_400_000;
const prevCode = codeAt(secret, now - PERIOD_MS);
const prevStep = currentTotpStep(now) - 1;
// The previous-step code is in-window but its step <= lastStep (current).
const res = verifyTotp(secret, prevCode, currentTotpStep(now), now);
expect(res.valid).toBe(false);
expect(res.replay).toBe(true);
expect(res.step).toBe(prevStep);
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Recovery (backup) codes — the lost-authenticator fallback for the TOTP
* second factor.
*
* - **10 single-use codes**, each ~64+ bits of CSPRNG entropy, rendered as
* two Base32 groups for legibility (`XXXXX-XXXXX`).
* - **Argon2id-hashed at rest** (the same primitive and cost parameters as
* the account password) — OWASP cautions that recovery-code keyspaces are
* small, so a slow hash is what makes the at-rest storage meaningful.
* - **Shown once** at TOTP confirm (and on explicit regeneration); only the
* hash is stored, so the plaintext set can never be re-derived server-side.
* - **One-time use:** verifying a code burns it (`usedAt`).
* - **Regeneration invalidates the entire prior set** in a transaction.
*
* Verification is constant-time-ish: the candidate is Argon2id-verified
* against every still-unused hash for the user (Argon2's verify is itself
* constant-time), so there is no early-exit oracle on which code matched.
*/
import { randomInt } from "node:crypto";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/db";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
export const RECOVERY_CODE_COUNT = 10;
/** Crockford-ish Base32 alphabet minus easily-confused glyphs (0/O, 1/I/L). */
const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
const GROUP_LENGTH = 5;
const GROUPS = 2; // 10 chars over a 29-symbol alphabet ≈ 49 bits/group pair → ~64+ bits set-wide guard below
/** A single high-entropy code, e.g. `7QF3K-MZ2WP`. */
function generateRecoveryCode(): string {
const groups: string[] = [];
for (let g = 0; g < GROUPS; g++) {
let group = "";
for (let i = 0; i < GROUP_LENGTH; i++) {
group += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
}
groups.push(group);
}
return groups.join("-");
}
/** Generate a fresh batch of plaintext codes (caller shows them once). */
export function generateRecoveryCodes(
count: number = RECOVERY_CODE_COUNT,
): string[] {
return Array.from({ length: count }, generateRecoveryCode);
}
/** Normalise user input: strip spaces, upper-case (the display is grouped). */
export function normaliseRecoveryCode(input: string): string {
return input.trim().toUpperCase().replace(/\s+/g, "");
}
type TxClient = Prisma.TransactionClient | PrismaClient;
/**
* Argon2id-hash a batch and persist one row per code. Runs inside the
* caller's transaction so the recovery set lands atomically with the
* pending→active promotion (confirm) or the prior-set deletion (regen).
*/
export async function persistRecoveryCodes(
tx: TxClient,
userId: string,
codes: string[],
): Promise<void> {
const hashes = await Promise.all(
codes.map((code) => hashPassword(normaliseRecoveryCode(code))),
);
await tx.mfaRecoveryCode.createMany({
data: hashes.map((codeHash) => ({ userId, codeHash })),
});
}
/**
* Replace the user's entire recovery-code set with a fresh batch and return
* the new plaintext codes (shown once). The delete + insert run in one
* transaction so a regeneration never leaves a half-rotated set.
*/
export async function regenerateRecoveryCodes(
userId: string,
count: number = RECOVERY_CODE_COUNT,
): Promise<string[]> {
const codes = generateRecoveryCodes(count);
await prisma.$transaction(async (tx) => {
await tx.mfaRecoveryCode.deleteMany({ where: { userId } });
await persistRecoveryCodes(tx, userId, codes);
});
return codes;
}
/** Count the codes a user still has available (unused). */
export async function countRemainingRecoveryCodes(
userId: string,
): Promise<number> {
return prisma.mfaRecoveryCode.count({ where: { userId, usedAt: null } });
}
/**
* Verify a candidate against the user's unused codes and burn it on match.
*
* The burn is a guarded `updateMany` (`usedAt: null` in the WHERE) so two
* concurrent verifications of the same code can never both succeed — the
* second update affects zero rows and is treated as a miss. Returns true
* only when exactly one row transitioned to used.
*/
export async function verifyAndConsumeRecoveryCode(
userId: string,
candidate: string,
): Promise<boolean> {
const normalised = normaliseRecoveryCode(candidate);
if (normalised.length === 0) return false;
const rows = await prisma.mfaRecoveryCode.findMany({
where: { userId, usedAt: null },
select: { id: true, codeHash: true },
});
for (const row of rows) {
// Argon2id verify is constant-time; iterate the full set so the match
// position is not observable through timing.
const match = await verifyPassword(row.codeHash, normalised);
if (!match) continue;
const burned = await prisma.mfaRecoveryCode.updateMany({
where: { id: row.id, usedAt: null },
data: { usedAt: new Date() },
});
return burned.count === 1;
}
return false;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* TOTP (RFC 6238) second-factor primitives.
*
* Thin wrapper over `otpauth` (zero runtime deps) pinning the de-facto
* interop floor — **SHA-1, 6 digits, 30-second period** — because
* SHA-256/512 break the common authenticator apps and hardware tokens.
* Drift window is **±1 step** (previous / current / next), the RFC's
* recommended bound; every extra step widens the replay surface.
*
* Replay protection (the commonly-omitted control) is enforced by the
* caller via the monotonic last-step counter: `verifyTotp` resolves the
* submitted code to its absolute time-step and returns it, and the caller
* rejects any step that is not strictly greater than the persisted
* `User.totpLastStep`. A captured code therefore dies the instant it is
* accepted once, even while it is still inside its 30-second life.
*
* The shared secret never leaves this module as anything but Base32; the
* `otpauth://` URI carries the secret and is built here only to hand to
* the enrolling client — it is never logged or persisted.
*/
import * as OTPAuth from "otpauth";
export const TOTP_ISSUER = "HealthLog";
export const TOTP_ALGORITHM = "SHA1";
export const TOTP_DIGITS = 6;
export const TOTP_PERIOD_SECONDS = 30;
/** ±1 step (≈ ±30 s clock skew). Do not widen without cause. */
export const TOTP_WINDOW = 1;
/** 160-bit secret per OWASP MFA / NIST 800-63B (`size` is bytes). */
const TOTP_SECRET_BYTES = 20;
/**
* Generate a fresh CSPRNG Base32 secret (160-bit). The returned string is
* the only form persisted (AES-256-GCM encrypted) and reconstituted for
* verification.
*/
export function generateTotpSecret(): string {
return new OTPAuth.Secret({ size: TOTP_SECRET_BYTES }).base32;
}
/**
* Build the `otpauth://totp/...` enrollment URI for a Base32 secret.
* Issuer is the fixed app name; `account` is the user-facing label
* (email or username). The URI carries the secret — hand it to the client,
* never log or persist it.
*/
export function buildOtpauthUri(secretBase32: string, account: string): string {
const totp = new OTPAuth.TOTP({
issuer: TOTP_ISSUER,
label: account,
algorithm: TOTP_ALGORITHM,
digits: TOTP_DIGITS,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
});
return totp.toString();
}
/** The absolute RFC 6238 time-step for a moment in time. */
export function currentTotpStep(atMs: number = Date.now()): number {
return Math.floor(atMs / 1000 / TOTP_PERIOD_SECONDS);
}
export interface TotpVerifyResult {
/** True only when the code is in-window AND not a replay. */
valid: boolean;
/**
* The absolute time-step the code resolved to (current step + drift
* delta). Present whenever the code matched the secret in-window — even
* when rejected as a replay — so the caller can log the reason. Persist
* this as the new `totpLastStep` on a successful, non-replay accept.
*/
step: number | null;
/** True when the code matched in-window but its step was already used. */
replay: boolean;
}
/**
* Verify a 6-digit code against a Base32 secret with ±1 drift and
* monotonic replay rejection.
*
* @param secretBase32 the user's stored TOTP secret
* @param code the submitted 6-digit code (non-digits → invalid)
* @param lastStep the user's persisted `totpLastStep` (null = none yet)
* @param atMs clock injection point for tests
*/
export function verifyTotp(
secretBase32: string,
code: string,
lastStep: number | null,
atMs: number = Date.now(),
): TotpVerifyResult {
const normalised = code.trim();
if (!/^\d{6}$/.test(normalised)) {
return { valid: false, step: null, replay: false };
}
const totp = new OTPAuth.TOTP({
issuer: TOTP_ISSUER,
label: TOTP_ISSUER,
algorithm: TOTP_ALGORITHM,
digits: TOTP_DIGITS,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
});
// `validate` returns the signed step delta (e.g. -1, 0, +1) when the
// code matches inside the window, or null when it does not. The compare
// is over the numeric HOTP truncation, not a string equality on the
// secret, so there is no early-exit timing oracle on the secret bytes.
const delta = totp.validate({
token: normalised,
timestamp: atMs,
window: TOTP_WINDOW,
});
if (delta === null) {
return { valid: false, step: null, replay: false };
}
const step = currentTotpStep(atMs) + delta;
// Monotonic replay guard: the resolved step must be strictly newer than
// the last one we accepted. Reusing an already-burned step — even one
// still inside its 30-second life — is rejected.
if (lastStep !== null && step <= lastStep) {
return { valid: false, step, replay: true };
}
return { valid: true, step, replay: false };
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Verify a presented second factor (TOTP or recovery code) for a user, with
* the side effects that make each factor single-use:
*
* - **TOTP:** decrypt the stored secret (fail-closed), validate with ±1 drift
* and monotonic replay rejection, and on success **persist the accepted
* time-step** so the same code cannot be replayed within its 30-second life.
* - **Recovery code:** Argon2id-verify against the unused set and burn the
* matched row.
*
* Used by both the login-completion route (`/api/auth/mfa/verify`) and the
* step-up-gated MFA-disable route, so the factor semantics are identical
* wherever a current factor must be proven.
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { verifyTotp } from "@/lib/auth/mfa/totp";
import { verifyAndConsumeRecoveryCode } from "@/lib/auth/mfa/recovery-codes";
export type MfaMethod = "totp" | "recovery";
export interface FactorUser {
id: string;
totpSecretEncrypted: string | null;
totpLastStep: bigint | null;
}
export interface FactorResult {
ok: boolean;
/** TOTP only: the code matched the secret but its step was already used. */
replay: boolean;
}
export async function verifyMfaFactor(
user: FactorUser,
method: MfaMethod,
code: string,
): Promise<FactorResult> {
if (method === "recovery") {
const ok = await verifyAndConsumeRecoveryCode(user.id, code);
return { ok, replay: false };
}
// TOTP.
if (!user.totpSecretEncrypted) return { ok: false, replay: false };
const secret = decrypt(user.totpSecretEncrypted);
const result = verifyTotp(
secret,
code,
user.totpLastStep === null ? null : Number(user.totpLastStep),
);
if (!result.valid) {
return { ok: false, replay: result.replay };
}
// Burn the step: persist it as the new floor so a replay of this exact
// code (still inside its 30-second window) is rejected. The guarded WHERE
// (`totpLastStep` unchanged) makes two concurrent accepts of the same step
// race-safe — only the first transitions the counter.
const advanced = await prisma.user.updateMany({
where: {
id: user.id,
OR: [
{ totpLastStep: null },
{ totpLastStep: { lt: BigInt(result.step as number) } },
],
},
data: { totpLastStep: BigInt(result.step as number) },
});
if (advanced.count !== 1) {
// Another request already consumed this (or a newer) step — treat as a
// replay rather than a fresh accept.
return { ok: false, replay: true };
}
return { ok: true, replay: false };
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Request schemas for the second-factor (MFA) surface. Single-sourced here so
* the runtime `safeParse` and the OpenAPI registry (`src/lib/openapi/routes/
* auth.ts`) describe the identical wire contract.
*/
import { z } from "zod/v4";
/** A 6-digit TOTP code. */
export const totpCodeSchema = z
.string()
.trim()
.regex(/^\d{6}$/, "Enter the 6-digit code from your authenticator app");
/**
* A recovery code as displayed (`XXXXX-XXXXX`) — accepted case-insensitively
* with or without the separator; the server normalises before comparing.
*/
export const recoveryCodeSchema = z.string().trim().min(8).max(32);
export const totpConfirmSchema = z.object({
code: totpCodeSchema,
});
/** Disable MFA: prove possession of a current factor (TOTP or recovery code). */
export const mfaDisableSchema = z
.object({
code: z.string().trim().min(6).max(32),
method: z.enum(["totp", "recovery"]).default("totp"),
})
.meta({
id: "MfaDisableRequest",
description:
"Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code.",
});
/** Login second-factor completion: ticket + a TOTP or recovery code. */
export const mfaVerifySchema = z
.object({
mfaTicket: z
.string()
.min(1)
.describe("The opaque single-use ticket from the login response."),
method: z.enum(["totp", "recovery"]).default("totp"),
code: z.string().trim().min(6).max(32),
})
.meta({
id: "MfaVerifyRequest",
description:
"Complete a second-factor login challenge and receive the same token bundle the password path issues.",
});