diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 79e597122..4055a7446 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1354,8 +1354,15 @@ paths: tags: - Auth summary: Email-or-username login (password) - description: "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA - prefix) additionally receive a paired access + refresh token." + description: >- + Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) + additionally receive a paired access + refresh token. + + + v1.23 — when the account has a confirmed second factor, the response carries no session/token. It returns HTTP + 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the + `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token + bundle. Accounts without MFA are unchanged. requestBody: required: true content: @@ -1374,11 +1381,124 @@ paths: - password responses: "200": - description: Login succeeded. + description: Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`). content: application/json: schema: - $ref: "#/components/schemas/LoginResponse" + anyOf: + - $ref: "#/components/schemas/LoginResponse" + - $ref: "#/components/schemas/MfaRequiredResponse" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/mfa/verify: + post: + tags: + - Auth + summary: Complete a second-factor login challenge + description: Presents the `mfaTicket` from the login `meta.mfaRequired` response plus a TOTP or recovery code. On + success returns the SAME token bundle / session the password path issues, with the session marked + second-factor-verified. The ticket is single-use; wrong codes are throttled and the ticket is burned at the + attempt cap. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MfaVerifyRequest" + responses: + "200": + description: Second factor verified — session + optional bearer issued. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaVerifyResponse" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/totp/setup: + post: + tags: + - Auth + summary: Begin TOTP enrollment (cookie session only) + description: Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is + not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA. + responses: + "200": + description: Pending secret created. + content: + application/json: + schema: + $ref: "#/components/schemas/TotpSetupEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/totp/confirm: + post: + tags: + - Auth + summary: Confirm TOTP enrollment (cookie session only) + description: Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. + Cookie-only. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + pattern: ^\d{6}$ + required: + - code + responses: + "200": + description: Factor activated — recovery codes returned once. + content: + application/json: + schema: + $ref: "#/components/schemas/TotpConfirmEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/disable: + post: + tags: + - Auth + summary: Disable the second factor (step-up gated) + description: Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the + secret and deletes recovery codes. Bearer can never satisfy the step-up gate. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MfaDisableRequest" + responses: + "200": + description: Factor disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaDisableEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/mfa/recovery-codes/regenerate: + post: + tags: + - Auth + summary: Regenerate recovery codes (step-up gated) + description: Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can + never satisfy the gate. + responses: + "200": + description: Fresh recovery codes issued. + content: + application/json: + schema: + $ref: "#/components/schemas/MfaRecoveryRegenEnvelope" "401": *a1 "422": *a3 "429": *a4 @@ -7492,6 +7612,43 @@ paths: "429": *a4 components: schemas: + MfaVerifyRequest: + type: object + properties: + mfaTicket: + type: string + minLength: 1 + description: The opaque single-use ticket from the login response. + method: + default: totp + type: string + enum: + - totp + - recovery + code: + type: string + minLength: 6 + maxLength: 32 + required: + - mfaTicket + - code + description: Complete a second-factor login challenge and receive the same token bundle the password path issues. + MfaDisableRequest: + type: object + properties: + code: + type: string + minLength: 6 + maxLength: 32 + method: + default: totp + type: string + enum: + - totp + - recovery + required: + - code + description: Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code. PasskeyLoginVerifyRequest: type: object properties: @@ -10660,6 +10817,166 @@ components: - tokenExpiresAt additionalProperties: false description: Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`. + MfaRequiredResponse: + type: object + properties: + data: + type: "null" + error: + type: "null" + meta: + type: object + properties: + mfaRequired: + type: boolean + const: true + mfaTicket: + type: string + description: Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify. + methods: + type: array + items: + type: string + enum: + - totp + - recovery + description: Second factors the account can complete the challenge with. + required: + - mfaRequired + - mfaTicket + - methods + additionalProperties: false + required: + - data + - error + - meta + additionalProperties: false + description: Password accepted but a second factor is required. Not an error and not a session — no token is issued + until /api/auth/mfa/verify succeeds. + MfaVerifyResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AccessRefreshBundle" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TotpSetupEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TotpSetupResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TotpSetupResponse: + type: object + properties: + otpauthUri: + type: string + description: otpauth:// URI to render as a QR code (carries the secret). + totpSecret: + type: string + description: Base32 secret for manual entry. Pending until confirmed. + required: + - otpauthUri + - totpSecret + additionalProperties: false + TotpConfirmEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MfaRecoveryCodesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MfaRecoveryCodesResponse: + type: object + properties: + enabled: + type: boolean + recoveryCodes: + type: array + items: + type: string + description: Single-use recovery codes, shown once. Save them now. + recoveryCodesRemaining: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - recoveryCodes + - recoveryCodesRemaining + additionalProperties: false + MfaDisableEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MfaToggleResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MfaToggleResponse: + type: object + properties: + enabled: + type: boolean + required: + - enabled + additionalProperties: false + MfaRecoveryRegenEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MfaRecoveryCodesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false PasskeyLoginVerifyResponse: type: object properties: diff --git a/package.json b/package.json index 29b6f8321..1474c7989 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 525664542..8d9e539c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/prisma/migrations/0202_v123_totp_laststep/migration.sql b/prisma/migrations/0202_v123_totp_laststep/migration.sql new file mode 100644 index 000000000..a4d5aa577 --- /dev/null +++ b/prisma/migrations/0202_v123_totp_laststep/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9aaf0ae5b..bf517cb88 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/scripts/disable-mfa.ts b/scripts/disable-mfa.ts new file mode 100644 index 000000000..456ccae76 --- /dev/null +++ b/scripts/disable-mfa.ts @@ -0,0 +1,90 @@ +/** + * scripts/disable-mfa.ts + * + * 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 ", + ); + process.exit(2); +} + +const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: DATABASE_URL }), +}); + +async function main(): Promise { + 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(); + }); diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts new file mode 100644 index 000000000..e47fc51fd --- /dev/null +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findFirst: vi.fn() } }, +})); +vi.mock("@/lib/auth/password", () => ({ + verifyPassword: vi.fn().mockResolvedValue(true), +})); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: Date.now() + 1e6, + ip: "203.0.113.7", + }), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ data: { user: { id: "user-1" } }, error: null }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ), +})); +vi.mock("@/lib/auth/mfa/challenge", () => ({ + createMfaChallenge: vi.fn().mockResolvedValue({ + ticket: "opaque-ticket-value", + expiresAt: new Date(Date.now() + 5 * 60_000), + }), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "@/app/api/auth/login/route"; +import { prisma } from "@/lib/db"; +import { finishLogin } from "@/lib/auth/login-response"; +import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; + +function loginRequest() { + return new Request("http://localhost/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "user@example.com", + password: "correct-horse-battery", + }), + }) as unknown as Parameters[0]; +} + +const BASE_USER = { + id: "user-1", + username: "u", + email: "user@example.com", + passwordHash: "argon2-hash", + onboardingCompletedAt: new Date(), +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("login MFA branch", () => { + it("MFA user → returns mfaRequired + ticket and issues NO session", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...BASE_USER, + totpConfirmedAt: new Date(), + } as never); + + const res = await POST(loginRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.data).toBeNull(); + expect(body.error).toBeNull(); + expect(body.meta.mfaRequired).toBe(true); + expect(body.meta.mfaTicket).toBe("opaque-ticket-value"); + expect(body.meta.methods).toEqual(["totp", "recovery"]); + + expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login"); + // The session/token tail is never reached. + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("non-MFA user → issues the session via finishLogin, no challenge", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...BASE_USER, + totpConfirmedAt: null, + } as never); + + const res = await POST(loginRequest()); + expect(res.status).toBe(200); + + expect(finishLogin).toHaveBeenCalledTimes(1); + expect(createMfaChallenge).not.toHaveBeenCalled(); + const arg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(arg.source).toBe("login.password"); + expect(arg.mfaVerified).toBeUndefined(); + }); + + it("wrong password never reaches the MFA branch", async () => { + const { verifyPassword } = await import("@/lib/auth/password"); + vi.mocked(verifyPassword).mockResolvedValueOnce(false); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...BASE_USER, + totpConfirmedAt: new Date(), + } as never); + + const res = await POST(loginRequest()); + expect(res.status).toBe(401); + expect(createMfaChallenge).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/api/auth/mfa/verify.test.ts b/src/__tests__/api/auth/mfa/verify.test.ts new file mode 100644 index 000000000..b16904be3 --- /dev/null +++ b/src/__tests__/api/auth/mfa/verify.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 1e6, + ip: "203.0.113.9", + }), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ data: { user: { id: "user-1" } }, error: null }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), +})); +vi.mock("@/lib/auth/mfa/challenge", () => ({ + loadActiveChallenge: vi.fn(), + recordChallengeFailure: vi + .fn() + .mockResolvedValue({ exhausted: false, attempts: 1 }), + claimChallenge: vi.fn(), +})); +vi.mock("@/lib/auth/mfa/verify-factor", () => ({ + verifyMfaFactor: vi.fn(), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "@/app/api/auth/mfa/verify/route"; +import { prisma } from "@/lib/db"; +import { finishLogin } from "@/lib/auth/login-response"; +import { + loadActiveChallenge, + recordChallengeFailure, + claimChallenge, +} from "@/lib/auth/mfa/challenge"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; + +function verifyRequest(body: unknown) { + return new Request("http://localhost/api/auth/mfa/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as Parameters[0]; +} + +const GOOD_BODY = { mfaTicket: "tkt", method: "totp", code: "123456" }; +const ACTIVE_CHALLENGE = { + id: "ch-1", + userId: "user-1", + kind: "login", + attempts: 0, + expiresAt: new Date(Date.now() + 60_000), +}; +const MFA_USER = { + id: "user-1", + username: "u", + email: "user@example.com", + onboardingCompletedAt: new Date(), + totpConfirmedAt: new Date(), + totpSecretEncrypted: "v2.cipher", + totpLastStep: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/auth/mfa/verify", () => { + it("happy path: valid factor → claims ticket and issues the bundle", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true, replay: false }); + vi.mocked(claimChallenge).mockResolvedValue(true); + + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(200); + + expect(claimChallenge).toHaveBeenCalledWith("ch-1"); + expect(finishLogin).toHaveBeenCalledTimes(1); + const arg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(arg.mfaVerified).toBe(true); + expect(arg.source).toBe("mfa.verify"); + }); + + it("invalid ticket → 401, no session", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(null); + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(401); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("wrong code → records failure, 401, no session", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: false, replay: false }); + + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(401); + expect(recordChallengeFailure).toHaveBeenCalledWith("ch-1"); + expect(claimChallenge).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("factor valid but claim lost (race) → 401, no session", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true, replay: false }); + vi.mocked(claimChallenge).mockResolvedValue(false); + + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(401); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("factor disabled between password and verify → failure, 401", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...MFA_USER, + totpConfirmedAt: null, + } as never); + + const res = await POST(verifyRequest(GOOD_BODY)); + expect(res.status).toBe(401); + expect(recordChallengeFailure).toHaveBeenCalledWith("ch-1"); + expect(verifyMfaFactor).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("malformed body → 422", async () => { + const res = await POST(verifyRequest({ mfaTicket: "", code: "x" })); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index c4e727ee0..d271cfd31 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,22 +1,16 @@ import { prisma } from "@/lib/db"; import { loginPasswordSchema } from "@/lib/validations/auth"; import { verifyPassword } from "@/lib/auth/password"; -import { createSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { apiError, safeJson } from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; -import { - resolveTokenPolicy, - shouldIssueBearerToken, - isCookielessNativeCaller, -} from "@/lib/auth/native-client"; -import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; +import { finishLogin } from "@/lib/auth/login-response"; +import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; export const POST = apiHandler(async (request: NextRequest) => { // v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter @@ -95,93 +89,55 @@ export const POST = apiHandler(async (request: NextRequest) => { } const ua = request.headers.get("user-agent"); - // v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the - // `hl_onboarding` cookie itself; pass the user's onboarding state - // through so the proxy can short-circuit the redirect before the - // page hydrates. - await createSession(user.id, user.onboardingCompletedAt == null, ip, ua); await auditLog("auth.login.password", { userId: user.id, ipAddress: ip, }); - annotate({ action: { name: "auth.login.password" } }); - - // v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs) - // get a 24h access token + 60d rotating refresh token. Web UAs keep the - // legacy 90d Bearer (issued only when X-Client-Type: native is set — - // browser flows continue to use the session cookie). - if ( - shouldIssueBearerToken(request.headers) || - isNativeClientRequest(request.headers) - ) { - const policy = resolveTokenPolicy(request.headers); - const deviceId = request.headers.get("x-device-id"); - - // M-3 hardening: a 60-day refresh token is only ever delivered to a - // genuinely cookie-less native caller. A browser spoofing - // `X-Client-Type: native` (Mozilla UA or an inbound session cookie) - // falls through to the plain access-token path below — never handed a - // long-lived secret into a DOM/XSS-reachable context. - if ( - policy.refreshTokenDays !== null && - isCookielessNativeCaller(request.headers) - ) { - const bundle = await issueAccessAndRefresh({ - userId: user.id, - policy, - deviceId, - userAgent: ua, - ipAddress: ip, - source: "login.password", - }); - await auditLog("auth.token.autoissue.native", { - userId: user.id, - ipAddress: ip, - details: { source: "login.password", policy: "native" }, - }); - annotate({ - action: { name: "auth.token.autoissue.native" }, - meta: { token_policy: "native" }, - }); - return apiSuccess({ - user: { id: user.id, username: user.username }, - token: bundle.accessToken, - tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(), - refreshToken: bundle.refreshToken, - refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(), - }); - } - - // Web policy with explicit X-Client-Type:native (legacy iOS auto-login) - const issued = await issueApiToken({ - userId: user.id, - name: `web auto-login ${new Date().toISOString()}`, - permissions: ["*"], - expiresInDays: policy.accessTokenDays, - }); - await auditLog("auth.token.autoissue.native", { + // v1.23 — second-factor gate. The password is correct, but an account with + // a confirmed TOTP factor is NOT yet authenticated: no `Session` row and no + // token bundle is minted here. Instead a single-use, ~5-minute MFA ticket is + // returned (the partial state lives in the ticket, never in a half-built + // session) and the client completes the login at `/api/auth/mfa/verify`. + // + // Enumeration note: `meta.mfaRequired` is only ever returned AFTER a valid + // password, and the invalid-credentials response above is identical + // regardless of MFA state — an attacker without the password cannot learn + // whether an account has MFA. + if (user.totpConfirmedAt) { + const challenge = await createMfaChallenge(user.id, "login"); + await auditLog("auth.mfa.challenge", { userId: user.id, ipAddress: ip, - details: { - tokenId: issued.tokenId, - source: "login.password", - policy: "web", - }, + details: { source: "login.password" }, }); annotate({ - action: { name: "auth.token.autoissue.native" }, - meta: { token_policy: "web" }, - }); - return apiSuccess({ - user: { id: user.id, username: user.username }, - token: issued.token, - tokenExpiresAt: issued.expiresAt.toISOString(), + action: { name: "auth.mfa.challenge" }, + meta: { mfa_required: true }, }); + return NextResponse.json( + { + data: null, + error: null, + meta: { + mfaRequired: true, + mfaTicket: challenge.ticket, + methods: ["totp", "recovery"], + }, + }, + { status: 200 }, + ); } - return apiSuccess({ - user: { id: user.id, username: user.username }, + annotate({ action: { name: "auth.login.password" } }); + + // No second factor — issue the session/token exactly as before. + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "login.password", }); }); diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts new file mode 100644 index 000000000..24a266288 --- /dev/null +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -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 }); +}); diff --git a/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts new file mode 100644 index 000000000..a82e3d87d --- /dev/null +++ b/src/app/api/auth/me/mfa/recovery-codes/regenerate/route.ts @@ -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, + }); +}); diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts new file mode 100644 index 000000000..861c7c7a3 --- /dev/null +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -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, + }); +}); diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts new file mode 100644 index 000000000..57fa885d5 --- /dev/null +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -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 }); +}); diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts new file mode 100644 index 000000000..adf7dfde8 --- /dev/null +++ b/src/app/api/auth/mfa/verify/route.ts @@ -0,0 +1,139 @@ +/** + * POST /api/auth/mfa/verify + * + * Complete a second-factor login challenge. The caller presents the opaque + * `mfaTicket` from the `meta.mfaRequired` login response plus a TOTP or + * recovery code. On success the **same** session/token bundle the password + * path issues is returned (web → session cookie; native → access+refresh), + * with the session stamped `mfaVerifiedAt`. + * + * Security-critical invariants: + * - No session or token exists until the factor passes — the partial state + * lived in the ticket, never in a half-built session. + * - The ticket is **claimed once, atomically**, only AFTER the factor + * verifies; two concurrent valid submissions can mint at most one session. + * - Wrong factors increment the ticket attempt counter and burn it at the + * cap (NIST throttle, not an account lock). Anonymous surface is rate- + * limited per IP on top of the per-ticket cap. + */ +import { NextRequest } from "next/server"; +import { apiError, safeJson } from "@/lib/api-response"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { finishLogin } from "@/lib/auth/login-response"; +import { + loadActiveChallenge, + recordChallengeFailure, + claimChallenge, +} from "@/lib/auth/mfa/challenge"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { mfaVerifySchema } from "@/lib/validations/mfa"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:mfa-verify", + 10, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; + if (!rl.allowed) { + return apiError("Too many attempts. Please try again later.", 429, { + headers: rateLimitHeaders(rl), + }); + } + + await ensureDbCompatibility(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = mfaVerifySchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + const { mfaTicket, method, code } = parsed.data; + + // Resolve the ticket. A single generic 401 covers unknown / expired / + // consumed / over-cap so a ticket-guesser learns nothing. + const challenge = await loadActiveChallenge(mfaTicket); + if (!challenge) { + annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } }); + return apiError("Invalid or expired challenge", 401); + } + + const user = await prisma.user.findUnique({ + where: { id: challenge.userId }, + select: { + id: true, + username: true, + email: true, + onboardingCompletedAt: true, + totpConfirmedAt: true, + totpSecretEncrypted: true, + totpLastStep: true, + }, + }); + + // The factor must still be active (it could have been disabled between the + // password step and this call). Treat anything else as a failed attempt. + if (!user || !user.totpConfirmedAt) { + await recordChallengeFailure(challenge.id); + annotate({ action: { name: "auth.mfa.verify.no_active_factor" } }); + return apiError("Invalid or expired challenge", 401); + } + + const factor = await verifyMfaFactor(user, method, code); + if (!factor.ok) { + const { exhausted } = await recordChallengeFailure(challenge.id); + await auditLog("auth.mfa.failed", { + userId: user.id, + ipAddress: ip, + details: { stage: "login", method, replay: factor.replay, exhausted }, + }); + annotate({ + action: { name: "auth.mfa.failed" }, + meta: { method, replay: factor.replay, exhausted }, + }); + return apiError("Invalid code", 401); + } + + // Factor verified — claim the ticket atomically. Only the single winning + // caller proceeds to mint a session; a concurrent claim loses here. + const claimed = await claimChallenge(challenge.id); + if (!claimed) { + annotate({ action: { name: "auth.mfa.verify.claim_lost" } }); + return apiError("Invalid or expired challenge", 401); + } + + await auditLog("auth.mfa.verified", { + userId: user.id, + ipAddress: ip, + details: { method }, + }); + if (method === "recovery") { + await auditLog("auth.mfa.recovery_used", { + userId: user.id, + ipAddress: ip, + }); + } + annotate({ action: { name: "auth.mfa.verified" }, meta: { method } }); + + const ua = request.headers.get("user-agent"); + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "mfa.verify", + mfaVerified: true, + }); +}); diff --git a/src/lib/__tests__/require-fresh-mfa.test.ts b/src/lib/__tests__/require-fresh-mfa.test.ts new file mode 100644 index 000000000..8093ab688 --- /dev/null +++ b/src/lib/__tests__/require-fresh-mfa.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mirrors require-auth-bearer.test.ts mocking so the api-handler module loads +// without a live DB / request scope. +vi.mock("@/lib/db", () => ({ + prisma: { + session: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { + requireFreshMfa, + StepUpRequiredError, + HttpError, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "../api-handler"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const MFA_USER = { + id: "user-1", + role: "USER" as const, + username: "u", + totpConfirmedAt: new Date("2020-01-01"), +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function mockSession(user: unknown) { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user, + } as never); +} + +describe("requireFreshMfa", () => { + it("passes for a cookie session verified within the window", async () => { + mockSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 60_000), // 1 min ago + } as never); + + const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + expect(ctx.user.id).toBe("user-1"); + expect(ctx.mfaVerifiedAt).toBeInstanceOf(Date); + }); + + it("rejects a stale verification (outside the window)", async () => { + mockSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 10 * 60_000), // 10 min ago + } as never); + + await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({ + name: "StepUpRequiredError", + errorCode: "auth.stepup.required", + statusCode: 401, + }); + }); + + it("rejects an absent verification stamp", async () => { + mockSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + + await expect(requireFreshMfa(5 * 60)).rejects.toBeInstanceOf( + StepUpRequiredError, + ); + }); + + it("rejects a user without an active second factor", async () => { + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({ + errorCode: "auth.stepup.mfa_not_enrolled", + }); + // Never even reaches the session-row read. + expect(prisma.session.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects when there is no cookie session — Bearer can never satisfy it", async () => { + // getSession is cookie-only; a Bearer header is irrelevant and never read. + headersGet.mockImplementation((n) => + n.toLowerCase() === "authorization" ? "Bearer hlk_xyz" : null, + ); + vi.mocked(getSession).mockResolvedValue(null as never); + + await expect(requireFreshMfa(5 * 60)).rejects.toBeInstanceOf(HttpError); + expect(prisma.session.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 4733b9ac2..d0761558f 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -191,6 +191,21 @@ export function apiHandler Promise>( }, { status: 403 }, ); + } else if (error instanceof StepUpRequiredError) { + // v1.23 — step-up gate not satisfied. Same 401 + meta.errorCode + // envelope shape as the assistant/consent gates so the client can + // branch on the stable code and launch a re-verification flow. + // Checked before the generic HttpError branch because + // StepUpRequiredError extends it. + evt.setError(error); + response = NextResponse.json( + { + data: null, + error: error.message, + meta: { errorCode: error.errorCode }, + }, + { status: error.statusCode }, + ); } else if (error instanceof HttpError) { evt.setError(error); response = NextResponse.json( @@ -441,6 +456,107 @@ export async function requireAdmin(): Promise { return sessionData; } +/** + * v1.23 — require a cookie-backed session, refusing Bearer tokens. + * + * Cookie-only by the same structural argument as `requireAdmin`: it resolves + * the session via `getSession()` (which reads only the session cookie) and + * never falls through to the Bearer branch. The second-factor management + * surfaces (TOTP enroll / confirm / disable / recovery-code regenerate) use + * this so an API token — even a wildcard one — can never enrol or tear down + * MFA on the account it belongs to. MFA management is a browser-only action. + */ +export async function requireCookieAuth(): Promise { + const sessionData = await getSession(); + if (!sessionData) throw new HttpError(401, "Not authenticated"); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + return sessionData; +} + +/** + * Error thrown when a step-up gate is not satisfied. Carries `errorCode` so + * the route can surface a stable machine code (`auth.stepup.required`) the + * client branches on to launch a re-verification flow rather than parsing + * prose. + */ +export class StepUpRequiredError extends HttpError { + constructor( + public errorCode: string = "auth.stepup.required", + message = "Recent second-factor verification required", + ) { + super(401, message); + this.name = "StepUpRequiredError"; + } +} + +/** + * Default step-up freshness window (5 minutes) for sensitive mutations. + * Within the 5–15 min band OWASP recommends; tight end because the gated + * actions (disable MFA, regenerate codes, and later key rotation / export) + * are destructive. + */ +export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60; + +export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; + +/** + * v1.23 — step-up gate. Passes only for a COOKIE session whose + * `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user has an + * active second factor (`totpConfirmedAt`). Throws `StepUpRequiredError` + * (401, `errorCode: "auth.stepup.required"`) otherwise. + * + * Bearer tokens can NEVER satisfy this — exactly like `requireAdmin`, the + * resolution path is `getSession()` (cookie-only) and there is no Bearer + * fall-through. A token transport carries no `mfaVerifiedAt` and cannot + * acquire one, so the boundary is structural, not a softenable runtime check. + * + * Consumed in Phase M by MFA disable + recovery-code regeneration; later + * waves gate account deletion, key rotation, and passphrase export on it. + */ +export async function requireFreshMfa( + maxAgeSeconds: number, +): Promise { + const sessionData = await getSession(); + if (!sessionData) throw new HttpError(401, "Not authenticated"); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + + // The user must actually have a second factor active. A single-factor + // account cannot produce a fresh-MFA proof, so step-up-gated actions are + // unreachable for it by design (the management UI gates enrolment first). + if (!sessionData.user.totpConfirmedAt) { + throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); + } + + // Read the freshness stamp off the live session row — `getSession`'s + // projection intentionally omits it. + const row = await prisma.session.findUnique({ + where: { id: sessionData.session.id }, + select: { mfaVerifiedAt: true }, + }); + const verifiedAt = row?.mfaVerifiedAt ?? null; + if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) { + throw new StepUpRequiredError(); + } + + return { ...sessionData, mfaVerifiedAt: verifiedAt }; +} + /** * Report unhandled errors to GlitchTip (fire-and-forget). * Uses dynamic import to avoid circular dependencies and startup cost. diff --git a/src/lib/auth/login-response.ts b/src/lib/auth/login-response.ts new file mode 100644 index 000000000..519c1f81d --- /dev/null +++ b/src/lib/auth/login-response.ts @@ -0,0 +1,125 @@ +/** + * Shared "issue the authenticated response" tail used by both the password + * login route and the `/api/auth/mfa/verify` route. + * + * Keeping one implementation guarantees the transport contract is identical + * whether or not a second factor sat in the middle: a web caller gets a + * session cookie, a cookie-less native caller gets the access + refresh + * bundle, and a legacy `X-Client-Type: native` caller gets a plain access + * token. The only behavioural difference the second factor introduces is the + * `mfaVerifiedAt` stamp threaded onto the issued session (cookie path) so + * step-up freshness can read it. + * + * Bearer-issuing branches do not carry an `mfaVerifiedAt` equivalent — the + * step-up primitive (`requireFreshMfa`) is cookie-only by construction, the + * same structural boundary as `requireAdmin`, so a token transport can never + * satisfy step-up regardless. + */ +import type { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { createSession } from "@/lib/auth/session"; +import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; +import { + resolveTokenPolicy, + shouldIssueBearerToken, + isCookielessNativeCaller, +} from "@/lib/auth/native-client"; +import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; +import type { User } from "@/generated/prisma/client"; + +export interface FinishLoginParams { + user: Pick; + request: NextRequest; + ip: string; + userAgent: string | null; + /** Audit `source` discriminator, e.g. `"login.password"` / `"mfa.verify"`. */ + source: string; + /** + * When true the issued session is stamped `mfaVerifiedAt = now()` so + * step-up gates treat it as freshly second-factor-verified. Only the + * cookie path carries the stamp. + */ + mfaVerified?: boolean; +} + +/** + * Mint the session/token for an already-authenticated user and return the + * client response. Caller is responsible for the prior factor checks and for + * its own `auth.login.*` / `auth.mfa.*` audit entries; this writes only the + * token-issuance audit rows. + */ +export async function finishLogin( + params: FinishLoginParams, +): Promise { + const { user, request, ip, userAgent, source, mfaVerified } = params; + + if ( + shouldIssueBearerToken(request.headers) || + isNativeClientRequest(request.headers) + ) { + const policy = resolveTokenPolicy(request.headers); + const deviceId = request.headers.get("x-device-id"); + + // A 60-day refresh token is only ever delivered to a genuinely + // cookie-less native caller — a browser spoofing `X-Client-Type: native` + // falls through to the plain access-token path below. + if ( + policy.refreshTokenDays !== null && + isCookielessNativeCaller(request.headers) + ) { + const bundle = await issueAccessAndRefresh({ + userId: user.id, + policy, + deviceId, + userAgent, + ipAddress: ip, + source, + }); + await auditLog("auth.token.autoissue.native", { + userId: user.id, + ipAddress: ip, + details: { source, policy: "native" }, + }); + return apiSuccess({ + user: { id: user.id, username: user.username }, + token: bundle.accessToken, + tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(), + refreshToken: bundle.refreshToken, + refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(), + }); + } + + // Web policy with explicit X-Client-Type:native (legacy iOS auto-login). + const issued = await issueApiToken({ + userId: user.id, + name: `web auto-login ${new Date().toISOString()}`, + permissions: ["*"], + expiresInDays: policy.accessTokenDays, + }); + await auditLog("auth.token.autoissue.native", { + userId: user.id, + ipAddress: ip, + details: { tokenId: issued.tokenId, source, policy: "web" }, + }); + return apiSuccess({ + user: { id: user.id, username: user.username }, + token: issued.token, + tokenExpiresAt: issued.expiresAt.toISOString(), + }); + } + + // Browser path — session cookie. `createSession` anchors the onboarding + // cookie itself; thread the user's onboarding state through. + await createSession( + user.id, + user.onboardingCompletedAt == null, + ip, + userAgent, + mfaVerified ? new Date() : null, + ); + + return apiSuccess({ + user: { id: user.id, username: user.username }, + }); +} diff --git a/src/lib/auth/mfa/__tests__/challenge.test.ts b/src/lib/auth/mfa/__tests__/challenge.test.ts new file mode 100644 index 000000000..74e4bfc47 --- /dev/null +++ b/src/lib/auth/mfa/__tests__/challenge.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + mfaChallenge: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +// Stand-in for the real HMAC: deterministic but NOT containing the raw +// ticket, so the "raw ticket is never persisted" assertion is meaningful. +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn((t: string) => `H${t.length}`), +})); + +import { + createMfaChallenge, + loadActiveChallenge, + recordChallengeFailure, + claimChallenge, + MFA_CHALLENGE_ATTEMPT_CAP, +} from "../challenge"; +import { prisma } from "@/lib/db"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function activeRow(overrides: Record = {}) { + return { + id: "ch-1", + userId: "user-1", + kind: "login", + attempts: 0, + expiresAt: new Date(Date.now() + 60_000), + consumedAt: null, + ...overrides, + }; +} + +describe("mfa challenge", () => { + it("mints a ticket and stores only its hash", async () => { + vi.mocked(prisma.mfaChallenge.create).mockResolvedValue({} as never); + const { ticket } = await createMfaChallenge("user-1", "login"); + expect(typeof ticket).toBe("string"); + expect(ticket.length).toBeGreaterThan(20); + const arg = vi.mocked(prisma.mfaChallenge.create).mock.calls[0][0]; + expect(arg.data.ticketHash).toBe(`H${ticket.length}`); + // Raw ticket is never persisted. + expect(JSON.stringify(arg)).not.toContain(ticket); + }); + + it("loads an active challenge by hash", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( + activeRow() as never, + ); + const res = await loadActiveChallenge("tkt"); + expect(res?.id).toBe("ch-1"); + expect( + vi.mocked(prisma.mfaChallenge.findUnique).mock.calls[0][0], + ).toMatchObject({ where: { ticketHash: "H3" } }); + }); + + it("refuses a consumed ticket (single-use)", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( + activeRow({ consumedAt: new Date() }) as never, + ); + expect(await loadActiveChallenge("tkt")).toBeNull(); + }); + + it("refuses an expired ticket (TTL)", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( + activeRow({ expiresAt: new Date(Date.now() - 1000) }) as never, + ); + expect(await loadActiveChallenge("tkt")).toBeNull(); + }); + + it("refuses a ticket at the attempt cap", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( + activeRow({ attempts: MFA_CHALLENGE_ATTEMPT_CAP }) as never, + ); + expect(await loadActiveChallenge("tkt")).toBeNull(); + }); + + it("refuses an unknown ticket", async () => { + vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue(null as never); + expect(await loadActiveChallenge("nope")).toBeNull(); + }); + + it("burns the ticket when the attempt cap is reached", async () => { + vi.mocked(prisma.mfaChallenge.update).mockResolvedValue({ + attempts: MFA_CHALLENGE_ATTEMPT_CAP, + } as never); + vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await recordChallengeFailure("ch-1"); + expect(res.exhausted).toBe(true); + // Burn sets consumedAt only while still null (claim-once guard). + expect(prisma.mfaChallenge.updateMany).toHaveBeenCalledWith({ + where: { id: "ch-1", consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + }); + + it("does not burn below the attempt cap", async () => { + vi.mocked(prisma.mfaChallenge.update).mockResolvedValue({ + attempts: 1, + } as never); + const res = await recordChallengeFailure("ch-1"); + expect(res.exhausted).toBe(false); + expect(prisma.mfaChallenge.updateMany).not.toHaveBeenCalled(); + }); + + it("claim-once: only the winning update returns true", async () => { + vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValueOnce({ + count: 1, + } as never); + expect(await claimChallenge("ch-1")).toBe(true); + + vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValueOnce({ + count: 0, + } as never); + expect(await claimChallenge("ch-1")).toBe(false); + }); +}); diff --git a/src/lib/auth/mfa/__tests__/recovery-codes.test.ts b/src/lib/auth/mfa/__tests__/recovery-codes.test.ts new file mode 100644 index 000000000..ed4b0cc4b --- /dev/null +++ b/src/lib/auth/mfa/__tests__/recovery-codes.test.ts @@ -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) => { + // 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); + }); +}); diff --git a/src/lib/auth/mfa/__tests__/totp.test.ts b/src/lib/auth/mfa/__tests__/totp.test.ts new file mode 100644 index 000000000..ae9e73dbf --- /dev/null +++ b/src/lib/auth/mfa/__tests__/totp.test.ts @@ -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); + }); +}); diff --git a/src/lib/auth/mfa/challenge.ts b/src/lib/auth/mfa/challenge.ts new file mode 100644 index 000000000..9e641982d --- /dev/null +++ b/src/lib/auth/mfa/challenge.ts @@ -0,0 +1,133 @@ +/** + * MfaChallenge — the short-lived, single-use login step-up ticket. + * + * When a password (or other primary credential) is accepted for an account + * that has a second factor enabled, **no session or token is issued**. + * Instead a challenge row is minted: the partial "password OK, awaiting + * factor 2" state lives entirely in this row, never in a half-built + * `Session`. The caller receives an opaque ticket; only its hash + * (`hashToken`) is stored, so a leaked database row cannot reconstruct a + * usable ticket. + * + * Guarantees enforced here: + * - **TTL** (~5 min) — `expiresAt`; an expired ticket is never loadable. + * - **Attempt cap** — `attempts` is incremented on every wrong factor and + * the ticket is burned (`consumedAt` set) once the cap is hit, forcing a + * fresh password login (NIST throttle, not an account lock). + * - **Claim-once** — consuming a ticket is an atomic guarded update + * (`consumedAt: null` in the WHERE), so two concurrent verifications can + * never both succeed and mint two sessions. The factor is verified first; + * the session is issued only after the claim wins. + */ +import { randomBytes } from "node:crypto"; +import { hashToken } from "@/lib/auth/hmac"; +import { prisma } from "@/lib/db"; + +/** Login step-up: the only `kind` Phase M exercises. */ +export type MfaChallengeKind = "login"; + +/** 5-minute ticket life — long enough to read a code, short enough to bound replay. */ +export const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; +/** Wrong-factor attempts before the ticket is burned. */ +export const MFA_CHALLENGE_ATTEMPT_CAP = 5; + +export interface CreatedChallenge { + /** The opaque ticket handed to the client (never stored in the clear). */ + ticket: string; + expiresAt: Date; +} + +/** Mint a fresh single-use challenge for a user/kind. */ +export async function createMfaChallenge( + userId: string, + kind: MfaChallengeKind, +): Promise { + const ticket = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + MFA_CHALLENGE_TTL_MS); + await prisma.mfaChallenge.create({ + data: { + userId, + kind, + ticketHash: hashToken(ticket), + expiresAt, + }, + }); + return { ticket, expiresAt }; +} + +export interface ActiveChallenge { + id: string; + userId: string; + kind: string; + attempts: number; + expiresAt: Date; +} + +/** + * Resolve a presented ticket to its live challenge, or null when it is + * unknown / already consumed / expired / over the attempt cap. The lookup + * is by hash — the raw ticket is never compared against a stored plaintext. + */ +export async function loadActiveChallenge( + ticket: string, +): Promise { + const row = await prisma.mfaChallenge.findUnique({ + where: { ticketHash: hashToken(ticket) }, + select: { + id: true, + userId: true, + kind: true, + attempts: true, + expiresAt: true, + consumedAt: true, + }, + }); + if (!row) return null; + if (row.consumedAt !== null) return null; + if (row.expiresAt.getTime() <= Date.now()) return null; + if (row.attempts >= MFA_CHALLENGE_ATTEMPT_CAP) return null; + return { + id: row.id, + userId: row.userId, + kind: row.kind, + attempts: row.attempts, + expiresAt: row.expiresAt, + }; +} + +/** + * Record a failed factor attempt. Increments `attempts`; when the cap is + * reached the ticket is burned (`consumedAt` set) so it cannot be retried. + * Returns whether the ticket is now exhausted. + */ +export async function recordChallengeFailure( + challengeId: string, +): Promise<{ exhausted: boolean; attempts: number }> { + const updated = await prisma.mfaChallenge.update({ + where: { id: challengeId }, + data: { attempts: { increment: 1 } }, + select: { attempts: true }, + }); + const exhausted = updated.attempts >= MFA_CHALLENGE_ATTEMPT_CAP; + if (exhausted) { + await prisma.mfaChallenge.updateMany({ + where: { id: challengeId, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + } + return { exhausted, attempts: updated.attempts }; +} + +/** + * Atomically claim the ticket. Returns true only for the single caller that + * transitions `consumedAt` from null → now. The factor MUST already be + * verified before this is called; the session is issued only when this + * returns true. + */ +export async function claimChallenge(challengeId: string): Promise { + const claimed = await prisma.mfaChallenge.updateMany({ + where: { id: challengeId, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + return claimed.count === 1; +} diff --git a/src/lib/auth/mfa/recovery-codes.ts b/src/lib/auth/mfa/recovery-codes.ts new file mode 100644 index 000000000..6e9caa54e --- /dev/null +++ b/src/lib/auth/mfa/recovery-codes.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/auth/mfa/totp.ts b/src/lib/auth/mfa/totp.ts new file mode 100644 index 000000000..5170c29e5 --- /dev/null +++ b/src/lib/auth/mfa/totp.ts @@ -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 }; +} diff --git a/src/lib/auth/mfa/verify-factor.ts b/src/lib/auth/mfa/verify-factor.ts new file mode 100644 index 000000000..7344787e5 --- /dev/null +++ b/src/lib/auth/mfa/verify-factor.ts @@ -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 { + 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 }; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 34de657f2..7a2c73e77 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -58,6 +58,11 @@ export async function createSession( onboardingPending: boolean, ipAddress?: string | null, userAgent?: string | null, + // v1.23 — set when this session was minted behind a completed second + // factor (the `/api/auth/mfa/verify` path) or a fresh factor re-confirm. + // `requireFreshMfa` reads it for step-up freshness; a null value means + // "single-factor session" and never satisfies step-up. + mfaVerifiedAt?: Date | null, ): Promise { const session = await prisma.session.create({ data: { @@ -65,6 +70,7 @@ export async function createSession( expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS), ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, + mfaVerifiedAt: mfaVerifiedAt ?? null, }, }); diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 77a6002ac..28736b8af 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -8,6 +8,11 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { loginPasswordSchema } from "@/lib/validations/auth"; +import { + mfaVerifySchema, + totpConfirmSchema, + mfaDisableSchema, +} from "@/lib/validations/mfa"; import { dataEnvelope, stdResponses } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -73,23 +78,193 @@ const accessRefreshBundle = z "Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.", }); +// ── v1.23 second-factor (MFA) shapes ───────────────────────────────── + +const mfaRequiredEnvelope = z + .object({ + data: z.null(), + error: z.null(), + meta: z.object({ + mfaRequired: z.literal(true), + mfaTicket: z + .string() + .describe( + "Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify.", + ), + methods: z + .array(z.enum(["totp", "recovery"])) + .describe( + "Second factors the account can complete the challenge with.", + ), + }), + }) + .meta({ + id: "MfaRequiredResponse", + description: + "Password accepted but a second factor is required. Not an error and not a session — no token is issued until /api/auth/mfa/verify succeeds.", + }); + +const totpSetupResponse = z + .object({ + otpauthUri: z + .string() + .describe("otpauth:// URI to render as a QR code (carries the secret)."), + totpSecret: z + .string() + .describe("Base32 secret for manual entry. Pending until confirmed."), + }) + .meta({ id: "TotpSetupResponse" }); + +const recoveryCodesResponse = z + .object({ + enabled: z.boolean().optional(), + recoveryCodes: z + .array(z.string()) + .describe("Single-use recovery codes, shown once. Save them now."), + recoveryCodesRemaining: z.number().int(), + }) + .meta({ id: "MfaRecoveryCodesResponse" }); + +const mfaToggleResponse = z + .object({ enabled: z.boolean() }) + .meta({ id: "MfaToggleResponse" }); + export const authPaths: NonNullable = { "/api/auth/login": { post: { tags: ["Auth"], summary: "Email-or-username login (password)", description: - "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.", + "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.\n\n" + + "v1.23 — when the account has a confirmed second factor, the response carries no session/token. It returns HTTP 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token bundle. Accounts without MFA are unchanged.", requestBody: { required: true, content: { "application/json": { schema: loginPasswordSchema } }, }, responses: { "200": { - description: "Login succeeded.", + description: + "Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`).", content: { "application/json": { - schema: dataEnvelope(accessRefreshBundle, "LoginResponse"), + schema: z.union([ + dataEnvelope(accessRefreshBundle, "LoginResponse"), + mfaRequiredEnvelope, + ]), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/mfa/verify": { + post: { + tags: ["Auth"], + summary: "Complete a second-factor login challenge", + description: + "Presents the `mfaTicket` from the login `meta.mfaRequired` response plus a TOTP or recovery code. On success returns the SAME token bundle / session the password path issues, with the session marked second-factor-verified. The ticket is single-use; wrong codes are throttled and the ticket is burned at the attempt cap.", + requestBody: { + required: true, + content: { "application/json": { schema: mfaVerifySchema } }, + }, + responses: { + "200": { + description: + "Second factor verified — session + optional bearer issued.", + content: { + "application/json": { + schema: dataEnvelope(accessRefreshBundle, "MfaVerifyResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/mfa/totp/setup": { + post: { + tags: ["Auth"], + summary: "Begin TOTP enrollment (cookie session only)", + description: + "Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA.", + responses: { + "200": { + description: "Pending secret created.", + content: { + "application/json": { + schema: dataEnvelope(totpSetupResponse, "TotpSetupEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/mfa/totp/confirm": { + post: { + tags: ["Auth"], + summary: "Confirm TOTP enrollment (cookie session only)", + description: + "Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. Cookie-only.", + requestBody: { + required: true, + content: { "application/json": { schema: totpConfirmSchema } }, + }, + responses: { + "200": { + description: "Factor activated — recovery codes returned once.", + content: { + "application/json": { + schema: dataEnvelope( + recoveryCodesResponse, + "TotpConfirmEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/mfa/disable": { + post: { + tags: ["Auth"], + summary: "Disable the second factor (step-up gated)", + description: + "Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the secret and deletes recovery codes. Bearer can never satisfy the step-up gate.", + requestBody: { + required: true, + content: { "application/json": { schema: mfaDisableSchema } }, + }, + responses: { + "200": { + description: "Factor disabled.", + content: { + "application/json": { + schema: dataEnvelope(mfaToggleResponse, "MfaDisableEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/mfa/recovery-codes/regenerate": { + post: { + tags: ["Auth"], + summary: "Regenerate recovery codes (step-up gated)", + description: + "Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can never satisfy the gate.", + responses: { + "200": { + description: "Fresh recovery codes issued.", + content: { + "application/json": { + schema: dataEnvelope( + recoveryCodesResponse, + "MfaRecoveryRegenEnvelope", + ), }, }, }, diff --git a/src/lib/validations/mfa.ts b/src/lib/validations/mfa.ts new file mode 100644 index 000000000..c4a4a1a04 --- /dev/null +++ b/src/lib/validations/mfa.ts @@ -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.", + }); diff --git a/tests/integration/mfa-login-flow.test.ts b/tests/integration/mfa-login-flow.test.ts new file mode 100644 index 000000000..f646d7ad5 --- /dev/null +++ b/tests/integration/mfa-login-flow.test.ts @@ -0,0 +1,155 @@ +/** + * Integration guard for the second-factor login path against real Postgres. + * + * Unit tests mock Prisma, so they cannot prove the security-critical DB-backed + * invariants: that a verified TOTP step is actually persisted (replay guard), + * that the recovery-code burn is durable and one-time, and that the challenge + * ticket can be claimed exactly once. This exercises all three end to end. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as OTPAuth from "otpauth"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +// hashToken (challenge ticket hashing) is fail-closed on a weak/absent key. +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-test-hmac-key-test-hmac-key-0123456789"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +function codeAt(secretBase32: string, atMs: number): string { + const totp = new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secretBase32), + }); + return totp.generate({ timestamp: atMs }); +} + +let secret: string; +let encrypt: (s: string) => string; + +beforeAll(async () => { + ({ encrypt } = await import("@/lib/crypto")); + const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); + secret = generateTotpSecret(); +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("MFA login flow (real Postgres)", () => { + it("TOTP verify persists the step and rejects a replay", async () => { + const { verifyMfaFactor } = await import("@/lib/auth/mfa/verify-factor"); + const prisma = getPrismaClient(); + + const user = await prisma.user.create({ + data: { + username: "totp-user", + email: "totp@example.test", + totpSecretEncrypted: encrypt(secret), + totpConfirmedAt: new Date(), + }, + }); + + // verifyMfaFactor verifies against the real wall clock, so the code must + // be generated for "now". + const code = codeAt(secret, Date.now()); + + const first = await verifyMfaFactor( + { id: user.id, totpSecretEncrypted: encrypt(secret), totpLastStep: null }, + "totp", + code, + ); + expect(first.ok).toBe(true); + + const afterFirst = await prisma.user.findUnique({ + where: { id: user.id }, + select: { totpLastStep: true }, + }); + expect(afterFirst?.totpLastStep).not.toBeNull(); + + // Replay the same code — the persisted step now floors it out. + const replay = await verifyMfaFactor( + { + id: user.id, + totpSecretEncrypted: encrypt(secret), + totpLastStep: afterFirst?.totpLastStep ?? null, + }, + "totp", + code, + ); + expect(replay.ok).toBe(false); + expect(replay.replay).toBe(true); + }); + + it("challenge is single-use and burns at the attempt cap", async () => { + const { + createMfaChallenge, + loadActiveChallenge, + claimChallenge, + recordChallengeFailure, + MFA_CHALLENGE_ATTEMPT_CAP, + } = await import("@/lib/auth/mfa/challenge"); + const prisma = getPrismaClient(); + + const user = await prisma.user.create({ + data: { username: "ch-user", email: "ch@example.test" }, + }); + + const { ticket } = await createMfaChallenge(user.id, "login"); + const active = await loadActiveChallenge(ticket); + expect(active).not.toBeNull(); + + // Claim-once: first claim wins, second loses, ticket no longer loadable. + expect(await claimChallenge(active!.id)).toBe(true); + expect(await claimChallenge(active!.id)).toBe(false); + expect(await loadActiveChallenge(ticket)).toBeNull(); + + // Attempt cap burns a fresh ticket. + const second = await createMfaChallenge(user.id, "login"); + const row = await loadActiveChallenge(second.ticket); + let exhausted = false; + for (let i = 0; i < MFA_CHALLENGE_ATTEMPT_CAP; i++) { + ({ exhausted } = await recordChallengeFailure(row!.id)); + } + expect(exhausted).toBe(true); + expect(await loadActiveChallenge(second.ticket)).toBeNull(); + }); + + it("recovery code is one-time-use and regeneration invalidates the set", async () => { + const { + generateRecoveryCodes, + persistRecoveryCodes, + verifyAndConsumeRecoveryCode, + countRemainingRecoveryCodes, + regenerateRecoveryCodes, + } = await import("@/lib/auth/mfa/recovery-codes"); + const prisma = getPrismaClient(); + + const user = await prisma.user.create({ + data: { username: "rec-user", email: "rec@example.test" }, + }); + + const codes = generateRecoveryCodes(); + await persistRecoveryCodes(prisma, user.id, codes); + expect(await countRemainingRecoveryCodes(user.id)).toBe(codes.length); + + // First use succeeds, second use of the same code fails. + expect(await verifyAndConsumeRecoveryCode(user.id, codes[0])).toBe(true); + expect(await verifyAndConsumeRecoveryCode(user.id, codes[0])).toBe(false); + expect(await countRemainingRecoveryCodes(user.id)).toBe(codes.length - 1); + + // Regeneration drops every prior code (including the one not yet used). + const fresh = await regenerateRecoveryCodes(user.id); + expect(await countRemainingRecoveryCodes(user.id)).toBe(fresh.length); + expect(await verifyAndConsumeRecoveryCode(user.id, codes[1])).toBe(false); + expect(await verifyAndConsumeRecoveryCode(user.id, fresh[0])).toBe(true); + }); +});