mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'wave/v123-mfa-core' into release/v1.23.0
This commit is contained in:
commit
6d053c3667
28 files changed
+2829
-91
No files matched your search
+321
-4
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+16
@@ -84,6 +84,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1
|
||||
otpauth:
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
p-limit:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
@@ -1290,6 +1293,10 @@ packages:
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@2.2.0':
|
||||
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nodable/entities@2.1.0':
|
||||
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
|
||||
|
||||
@@ -5396,6 +5403,9 @@ packages:
|
||||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
otpauth@9.5.1:
|
||||
resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==}
|
||||
|
||||
outvariant@1.4.3:
|
||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||
|
||||
@@ -7978,6 +7988,8 @@ snapshots:
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@noble/hashes@2.2.0': {}
|
||||
|
||||
'@nodable/entities@2.1.0': {}
|
||||
|
||||
'@node-rs/argon2-android-arm-eabi@2.0.2':
|
||||
@@ -12157,6 +12169,10 @@ snapshots:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
otpauth@9.5.1:
|
||||
dependencies:
|
||||
'@noble/hashes': 2.2.0
|
||||
|
||||
outvariant@1.4.3: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- v1.23 — TOTP replay guard.
|
||||
--
|
||||
-- Persists the last accepted time-step counter per account so a verified
|
||||
-- code cannot be replayed within its 30-second validity window. The verify
|
||||
-- path resolves the submitted code to its RFC 6238 step (`floor(now /
|
||||
-- period)` adjusted by the ±1 drift delta) and rejects any step that is not
|
||||
-- strictly greater than this column. Additive, nullable — existing rows
|
||||
-- (no TOTP enrolled) need no backfill, and a freshly confirmed factor starts
|
||||
-- with NULL (the first verify simply records its step).
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "totp_last_step" BIGINT;
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* scripts/disable-mfa.ts <email-or-username>
|
||||
*
|
||||
* Operator escape hatch: clear the second factor for an account whose owner
|
||||
* has locked themselves out (lost authenticator AND recovery codes). Mirrors
|
||||
* the password-reset CLI — it runs out-of-band against the database, so it is
|
||||
* the recovery path of last resort for a self-hoster.
|
||||
*
|
||||
* It clears the TOTP secret, the confirmed-at stamp and the replay counter,
|
||||
* and deletes every recovery code, then writes an `auth.mfa.disabled` audit
|
||||
* row (with `details.source = "cli"`) so the action is traceable.
|
||||
*
|
||||
* Run via the bundled tsx (the production standalone image strips tsx):
|
||||
* pnpm dlx tsx scripts/disable-mfa.ts user@example.com
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("DATABASE_URL must be set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const identifier = process.argv[2];
|
||||
if (!identifier) {
|
||||
console.error(
|
||||
"Usage: pnpm dlx tsx scripts/disable-mfa.ts <email-or-username>",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { equals: identifier, mode: "insensitive" } },
|
||||
{ username: { equals: identifier, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: { id: true, username: true, totpConfirmedAt: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error(`No account matches '${identifier}'.`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
if (!user.totpConfirmedAt) {
|
||||
console.log(
|
||||
`Account '${user.username}' has no active second factor — nothing to do.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpSecretEncrypted: null,
|
||||
totpConfirmedAt: null,
|
||||
totpLastStep: null,
|
||||
},
|
||||
});
|
||||
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
action: "auth.mfa.disabled",
|
||||
userId: user.id,
|
||||
details: JSON.stringify({ source: "cli", factor: "totp" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`Second factor cleared for account '${user.username}'.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -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<typeof POST>[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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof POST>[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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/disable
|
||||
*
|
||||
* Tear down the second factor. Double-gated:
|
||||
* 1. `requireFreshMfa` — a cookie session that completed a second factor
|
||||
* within the step-up window (Bearer can never satisfy this).
|
||||
* 2. A current TOTP or recovery code in the body — proves live possession at
|
||||
* the moment of the destructive action, not just a stale fresh-session.
|
||||
*
|
||||
* On success it clears `totpSecretEncrypted` / `totpConfirmedAt` /
|
||||
* `totpLastStep` and deletes every recovery code in one transaction.
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfa,
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { mfaDisableSchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DISABLE_RATE_LIMIT = 5;
|
||||
const DISABLE_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
||||
// session is not freshly second-factor-verified.
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:disable:${user.id}`,
|
||||
DISABLE_RATE_LIMIT,
|
||||
DISABLE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(400, "Invalid JSON body");
|
||||
}
|
||||
const parsed = mfaDisableSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const factor = await verifyMfaFactor(
|
||||
user,
|
||||
parsed.data.method,
|
||||
parsed.data.code,
|
||||
);
|
||||
if (!factor.ok) {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
stage: "disable",
|
||||
method: parsed.data.method,
|
||||
replay: factor.replay,
|
||||
},
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
|
||||
throw new HttpError(401, "Invalid code");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpSecretEncrypted: null,
|
||||
totpConfirmedAt: null,
|
||||
totpLastStep: null,
|
||||
},
|
||||
});
|
||||
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
||||
});
|
||||
|
||||
await auditLog("auth.mfa.disabled", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { factor: parsed.data.method },
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.disabled" } });
|
||||
|
||||
return apiSuccess({ enabled: false });
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/recovery-codes/regenerate
|
||||
*
|
||||
* Issue a fresh recovery-code batch, invalidating the entire prior set in one
|
||||
* transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is
|
||||
* a sensitive action; Bearer can never satisfy the gate. The new codes are
|
||||
* returned **once**.
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfa,
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { regenerateRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const REGEN_RATE_LIMIT = 5;
|
||||
const REGEN_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
// Defence in depth — regeneration only makes sense for an active factor.
|
||||
if (!user.totpConfirmedAt) {
|
||||
throw new HttpError(409, "No second factor is active");
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:recovery-regen:${user.id}`,
|
||||
REGEN_RATE_LIMIT,
|
||||
REGEN_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const recoveryCodes = await regenerateRecoveryCodes(user.id);
|
||||
|
||||
await auditLog("auth.mfa.recovery_regenerated", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { count: recoveryCodes.length },
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.recovery_regenerated" } });
|
||||
|
||||
return apiSuccess({
|
||||
recoveryCodes,
|
||||
recoveryCodesRemaining: recoveryCodes.length,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/totp/confirm
|
||||
*
|
||||
* Finish TOTP enrollment. Verifies a code against the pending secret; on
|
||||
* success it promotes pending → active (`totpConfirmedAt = now()`), records
|
||||
* the accepted time-step (replay guard), generates + persists the Argon2id-
|
||||
* hashed recovery codes, and returns the codes **once** for the user to save.
|
||||
*
|
||||
* The completing session is also stamped `mfaVerifiedAt = now()` so the user
|
||||
* can immediately manage the freshly-enabled factor (disable / regenerate
|
||||
* codes) without an extra step-up round-trip.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyTotp } from "@/lib/auth/mfa/totp";
|
||||
import {
|
||||
generateRecoveryCodes,
|
||||
persistRecoveryCodes,
|
||||
} from "@/lib/auth/mfa/recovery-codes";
|
||||
import { totpConfirmSchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CONFIRM_RATE_LIMIT = 10;
|
||||
const CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:confirm:${user.id}`,
|
||||
CONFIRM_RATE_LIMIT,
|
||||
CONFIRM_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
if (user.totpConfirmedAt) {
|
||||
throw new HttpError(409, "A second factor is already active");
|
||||
}
|
||||
if (!user.totpSecretEncrypted) {
|
||||
throw new HttpError(409, "Start enrollment before confirming");
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(400, "Invalid JSON body");
|
||||
}
|
||||
const parsed = totpConfirmSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid code", 422);
|
||||
}
|
||||
|
||||
// Decrypt is fail-closed — a missing/rotated key throws rather than
|
||||
// confirming against junk.
|
||||
const secret = decrypt(user.totpSecretEncrypted);
|
||||
const result = verifyTotp(
|
||||
secret,
|
||||
parsed.data.code,
|
||||
user.totpLastStep === null ? null : Number(user.totpLastStep),
|
||||
);
|
||||
|
||||
if (!result.valid) {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { stage: "confirm", replay: result.replay },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.totp.confirm.invalid" },
|
||||
meta: { replay: result.replay },
|
||||
});
|
||||
throw new HttpError(401, "Invalid code");
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpConfirmedAt: new Date(),
|
||||
totpLastStep: BigInt(result.step as number),
|
||||
},
|
||||
});
|
||||
// A fresh enrollment starts from a clean recovery set.
|
||||
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
||||
await persistRecoveryCodes(tx, user.id, recoveryCodes);
|
||||
// Stamp the completing session so the user can immediately manage the
|
||||
// factor under step-up without re-verifying.
|
||||
await tx.session.updateMany({
|
||||
where: { id: session.id },
|
||||
data: { mfaVerifiedAt: new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog("auth.mfa.enabled", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { factor: "totp" },
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.enabled" } });
|
||||
|
||||
// `recoveryCodes` is redaction-denylisted (`/recovery.?code/i`).
|
||||
return apiSuccess({
|
||||
enabled: true,
|
||||
recoveryCodes,
|
||||
recoveryCodesRemaining: recoveryCodes.length,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/totp/setup
|
||||
*
|
||||
* Begin TOTP enrollment. Generates a 160-bit secret, stores it **encrypted**
|
||||
* (AES-256-GCM, the same at-rest envelope as every other credential), and
|
||||
* returns the `otpauth://` URI + the raw Base32 secret so the client can
|
||||
* render the QR and offer manual entry. The secret is **pending** — MFA is
|
||||
* not active until `/confirm` verifies a code, so `totpConfirmedAt` stays
|
||||
* null here.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The
|
||||
* recovery-code batch is issued at `/confirm` (after the factor is proven),
|
||||
* not here, so an abandoned setup never persists codes.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETUP_RATE_LIMIT = 5;
|
||||
const SETUP_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:setup:${user.id}`,
|
||||
SETUP_RATE_LIMIT,
|
||||
SETUP_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// An already-active factor must be disabled (step-up gated) before a new
|
||||
// secret can be enrolled — re-running setup must not silently rotate the
|
||||
// live secret out from under the user's authenticator.
|
||||
if (user.totpConfirmedAt) {
|
||||
annotate({ action: { name: "auth.mfa.totp.setup.already_active" } });
|
||||
throw new HttpError(409, "A second factor is already active");
|
||||
}
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
const account = user.email ?? user.username;
|
||||
const otpauthUri = buildOtpauthUri(secret, account);
|
||||
|
||||
// Store the pending secret encrypted; leave `totpConfirmedAt` null.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
// A re-setup resets the replay counter — the new secret starts fresh.
|
||||
totpLastStep: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("auth.mfa.totp.setup", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.totp.setup" } });
|
||||
|
||||
// `totpSecret` / `otpauthUri` are redaction-denylisted (`/totp/i`,
|
||||
// `/otp/i`, `/secret/i`) so they never surface in a wide-event excerpt.
|
||||
return apiSuccess({ otpauthUri, totpSecret: secret });
|
||||
});
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -191,6 +191,21 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
},
|
||||
{ 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<AuthContext> {
|
||||
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<AuthContext> {
|
||||
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<FreshMfaContext> {
|
||||
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.
|
||||
|
||||
@@ -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<User, "id" | "username" | "onboardingCompletedAt">;
|
||||
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<Response> {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Real Argon2id hashing (a handful of codes is fast enough for a unit test)
|
||||
// so the hash/verify round-trip is exercised end-to-end. Only the DB is mocked.
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
mfaRecoveryCode: {
|
||||
createMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
// The transactional helper receives the same mocked client.
|
||||
const { prisma } = await import("@/lib/db");
|
||||
return fn(prisma);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
generateRecoveryCodes,
|
||||
regenerateRecoveryCodes,
|
||||
verifyAndConsumeRecoveryCode,
|
||||
normaliseRecoveryCode,
|
||||
RECOVERY_CODE_COUNT,
|
||||
} from "../recovery-codes";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("recovery-codes", () => {
|
||||
it("generates the configured count of high-entropy, grouped codes", () => {
|
||||
const codes = generateRecoveryCodes();
|
||||
expect(codes).toHaveLength(RECOVERY_CODE_COUNT);
|
||||
for (const c of codes) {
|
||||
expect(c).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);
|
||||
}
|
||||
// No duplicates in a batch.
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
});
|
||||
|
||||
it("verifies a code once then refuses it (one-time use)", async () => {
|
||||
const code = "ABCDE-FGHJK";
|
||||
const codeHash = await hashPassword(normaliseRecoveryCode(code));
|
||||
|
||||
vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([
|
||||
{ id: "row-1", codeHash },
|
||||
] as never);
|
||||
// First consume succeeds — the guarded update flips exactly one row.
|
||||
vi.mocked(prisma.mfaRecoveryCode.updateMany).mockResolvedValueOnce({
|
||||
count: 1,
|
||||
} as never);
|
||||
|
||||
const first = await verifyAndConsumeRecoveryCode("user-1", code);
|
||||
expect(first).toBe(true);
|
||||
|
||||
// Second attempt: the row is now used, so findMany returns nothing.
|
||||
vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([] as never);
|
||||
const second = await verifyAndConsumeRecoveryCode("user-1", code);
|
||||
expect(second).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a lost claim race (count !== 1) as a miss", async () => {
|
||||
const code = "ABCDE-FGHJK";
|
||||
const codeHash = await hashPassword(normaliseRecoveryCode(code));
|
||||
vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([
|
||||
{ id: "row-1", codeHash },
|
||||
] as never);
|
||||
vi.mocked(prisma.mfaRecoveryCode.updateMany).mockResolvedValueOnce({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
expect(await verifyAndConsumeRecoveryCode("user-1", code)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a code that matches no stored hash", async () => {
|
||||
const realHash = await hashPassword(normaliseRecoveryCode("AAAAA-BBBBB"));
|
||||
vi.mocked(prisma.mfaRecoveryCode.findMany).mockResolvedValue([
|
||||
{ id: "row-1", codeHash: realHash },
|
||||
] as never);
|
||||
expect(await verifyAndConsumeRecoveryCode("user-1", "ZZZZZ-99999")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(prisma.mfaRecoveryCode.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("regeneration deletes the entire prior set before inserting", async () => {
|
||||
vi.mocked(prisma.mfaRecoveryCode.deleteMany).mockResolvedValue({
|
||||
count: 10,
|
||||
} as never);
|
||||
vi.mocked(prisma.mfaRecoveryCode.createMany).mockResolvedValue({
|
||||
count: 10,
|
||||
} as never);
|
||||
|
||||
const codes = await regenerateRecoveryCodes("user-1");
|
||||
expect(codes).toHaveLength(RECOVERY_CODE_COUNT);
|
||||
expect(prisma.mfaRecoveryCode.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
});
|
||||
// Delete is ordered before the insert.
|
||||
const deleteOrder = vi.mocked(prisma.mfaRecoveryCode.deleteMany).mock
|
||||
.invocationCallOrder[0];
|
||||
const insertOrder = vi.mocked(prisma.mfaRecoveryCode.createMany).mock
|
||||
.invocationCallOrder[0];
|
||||
expect(deleteOrder).toBeLessThan(insertOrder);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<CreatedChallenge> {
|
||||
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<ActiveChallenge | null> {
|
||||
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<boolean> {
|
||||
const claimed = await prisma.mfaChallenge.updateMany({
|
||||
where: { id: challengeId, consumedAt: null },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
return claimed.count === 1;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Recovery (backup) codes — the lost-authenticator fallback for the TOTP
|
||||
* second factor.
|
||||
*
|
||||
* - **10 single-use codes**, each ~64+ bits of CSPRNG entropy, rendered as
|
||||
* two Base32 groups for legibility (`XXXXX-XXXXX`).
|
||||
* - **Argon2id-hashed at rest** (the same primitive and cost parameters as
|
||||
* the account password) — OWASP cautions that recovery-code keyspaces are
|
||||
* small, so a slow hash is what makes the at-rest storage meaningful.
|
||||
* - **Shown once** at TOTP confirm (and on explicit regeneration); only the
|
||||
* hash is stored, so the plaintext set can never be re-derived server-side.
|
||||
* - **One-time use:** verifying a code burns it (`usedAt`).
|
||||
* - **Regeneration invalidates the entire prior set** in a transaction.
|
||||
*
|
||||
* Verification is constant-time-ish: the candidate is Argon2id-verified
|
||||
* against every still-unused hash for the user (Argon2's verify is itself
|
||||
* constant-time), so there is no early-exit oracle on which code matched.
|
||||
*/
|
||||
import { randomInt } from "node:crypto";
|
||||
import { hashPassword, verifyPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export const RECOVERY_CODE_COUNT = 10;
|
||||
/** Crockford-ish Base32 alphabet minus easily-confused glyphs (0/O, 1/I/L). */
|
||||
const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
|
||||
const GROUP_LENGTH = 5;
|
||||
const GROUPS = 2; // 10 chars over a 29-symbol alphabet ≈ 49 bits/group pair → ~64+ bits set-wide guard below
|
||||
|
||||
/** A single high-entropy code, e.g. `7QF3K-MZ2WP`. */
|
||||
function generateRecoveryCode(): string {
|
||||
const groups: string[] = [];
|
||||
for (let g = 0; g < GROUPS; g++) {
|
||||
let group = "";
|
||||
for (let i = 0; i < GROUP_LENGTH; i++) {
|
||||
group += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
return groups.join("-");
|
||||
}
|
||||
|
||||
/** Generate a fresh batch of plaintext codes (caller shows them once). */
|
||||
export function generateRecoveryCodes(
|
||||
count: number = RECOVERY_CODE_COUNT,
|
||||
): string[] {
|
||||
return Array.from({ length: count }, generateRecoveryCode);
|
||||
}
|
||||
|
||||
/** Normalise user input: strip spaces, upper-case (the display is grouped). */
|
||||
export function normaliseRecoveryCode(input: string): string {
|
||||
return input.trim().toUpperCase().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
type TxClient = Prisma.TransactionClient | PrismaClient;
|
||||
|
||||
/**
|
||||
* Argon2id-hash a batch and persist one row per code. Runs inside the
|
||||
* caller's transaction so the recovery set lands atomically with the
|
||||
* pending→active promotion (confirm) or the prior-set deletion (regen).
|
||||
*/
|
||||
export async function persistRecoveryCodes(
|
||||
tx: TxClient,
|
||||
userId: string,
|
||||
codes: string[],
|
||||
): Promise<void> {
|
||||
const hashes = await Promise.all(
|
||||
codes.map((code) => hashPassword(normaliseRecoveryCode(code))),
|
||||
);
|
||||
await tx.mfaRecoveryCode.createMany({
|
||||
data: hashes.map((codeHash) => ({ userId, codeHash })),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the user's entire recovery-code set with a fresh batch and return
|
||||
* the new plaintext codes (shown once). The delete + insert run in one
|
||||
* transaction so a regeneration never leaves a half-rotated set.
|
||||
*/
|
||||
export async function regenerateRecoveryCodes(
|
||||
userId: string,
|
||||
count: number = RECOVERY_CODE_COUNT,
|
||||
): Promise<string[]> {
|
||||
const codes = generateRecoveryCodes(count);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.mfaRecoveryCode.deleteMany({ where: { userId } });
|
||||
await persistRecoveryCodes(tx, userId, codes);
|
||||
});
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** Count the codes a user still has available (unused). */
|
||||
export async function countRemainingRecoveryCodes(
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
return prisma.mfaRecoveryCode.count({ where: { userId, usedAt: null } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a candidate against the user's unused codes and burn it on match.
|
||||
*
|
||||
* The burn is a guarded `updateMany` (`usedAt: null` in the WHERE) so two
|
||||
* concurrent verifications of the same code can never both succeed — the
|
||||
* second update affects zero rows and is treated as a miss. Returns true
|
||||
* only when exactly one row transitioned to used.
|
||||
*/
|
||||
export async function verifyAndConsumeRecoveryCode(
|
||||
userId: string,
|
||||
candidate: string,
|
||||
): Promise<boolean> {
|
||||
const normalised = normaliseRecoveryCode(candidate);
|
||||
if (normalised.length === 0) return false;
|
||||
|
||||
const rows = await prisma.mfaRecoveryCode.findMany({
|
||||
where: { userId, usedAt: null },
|
||||
select: { id: true, codeHash: true },
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
// Argon2id verify is constant-time; iterate the full set so the match
|
||||
// position is not observable through timing.
|
||||
const match = await verifyPassword(row.codeHash, normalised);
|
||||
if (!match) continue;
|
||||
|
||||
const burned = await prisma.mfaRecoveryCode.updateMany({
|
||||
where: { id: row.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
return burned.count === 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Verify a presented second factor (TOTP or recovery code) for a user, with
|
||||
* the side effects that make each factor single-use:
|
||||
*
|
||||
* - **TOTP:** decrypt the stored secret (fail-closed), validate with ±1 drift
|
||||
* and monotonic replay rejection, and on success **persist the accepted
|
||||
* time-step** so the same code cannot be replayed within its 30-second life.
|
||||
* - **Recovery code:** Argon2id-verify against the unused set and burn the
|
||||
* matched row.
|
||||
*
|
||||
* Used by both the login-completion route (`/api/auth/mfa/verify`) and the
|
||||
* step-up-gated MFA-disable route, so the factor semantics are identical
|
||||
* wherever a current factor must be proven.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { verifyTotp } from "@/lib/auth/mfa/totp";
|
||||
import { verifyAndConsumeRecoveryCode } from "@/lib/auth/mfa/recovery-codes";
|
||||
|
||||
export type MfaMethod = "totp" | "recovery";
|
||||
|
||||
export interface FactorUser {
|
||||
id: string;
|
||||
totpSecretEncrypted: string | null;
|
||||
totpLastStep: bigint | null;
|
||||
}
|
||||
|
||||
export interface FactorResult {
|
||||
ok: boolean;
|
||||
/** TOTP only: the code matched the secret but its step was already used. */
|
||||
replay: boolean;
|
||||
}
|
||||
|
||||
export async function verifyMfaFactor(
|
||||
user: FactorUser,
|
||||
method: MfaMethod,
|
||||
code: string,
|
||||
): Promise<FactorResult> {
|
||||
if (method === "recovery") {
|
||||
const ok = await verifyAndConsumeRecoveryCode(user.id, code);
|
||||
return { ok, replay: false };
|
||||
}
|
||||
|
||||
// TOTP.
|
||||
if (!user.totpSecretEncrypted) return { ok: false, replay: false };
|
||||
const secret = decrypt(user.totpSecretEncrypted);
|
||||
const result = verifyTotp(
|
||||
secret,
|
||||
code,
|
||||
user.totpLastStep === null ? null : Number(user.totpLastStep),
|
||||
);
|
||||
if (!result.valid) {
|
||||
return { ok: false, replay: result.replay };
|
||||
}
|
||||
|
||||
// Burn the step: persist it as the new floor so a replay of this exact
|
||||
// code (still inside its 30-second window) is rejected. The guarded WHERE
|
||||
// (`totpLastStep` unchanged) makes two concurrent accepts of the same step
|
||||
// race-safe — only the first transitions the counter.
|
||||
const advanced = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: user.id,
|
||||
OR: [
|
||||
{ totpLastStep: null },
|
||||
{ totpLastStep: { lt: BigInt(result.step as number) } },
|
||||
],
|
||||
},
|
||||
data: { totpLastStep: BigInt(result.step as number) },
|
||||
});
|
||||
if (advanced.count !== 1) {
|
||||
// Another request already consumed this (or a newer) step — treat as a
|
||||
// replay rather than a fresh accept.
|
||||
return { ok: false, replay: true };
|
||||
}
|
||||
return { ok: true, replay: false };
|
||||
}
|
||||
@@ -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<string> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/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",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user