Merge branch 'wave/v123-mfa-core' into release/v1.23.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 19:58:32 +02:00
commit 6d053c3667
28 files changed
+2829 -91

No files matched your search

+40 -84
View File
@@ -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",
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* POST /api/auth/me/mfa/disable
*
* Tear down the second factor. Double-gated:
* 1. `requireFreshMfa` — a cookie session that completed a second factor
* within the step-up window (Bearer can never satisfy this).
* 2. A current TOTP or recovery code in the body — proves live possession at
* the moment of the destructive action, not just a stale fresh-session.
*
* On success it clears `totpSecretEncrypted` / `totpConfirmedAt` /
* `totpLastStep` and deletes every recovery code in one transaction.
*/
import {
apiHandler,
requireFreshMfa,
HttpError,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { mfaDisableSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
const DISABLE_RATE_LIMIT = 5;
const DISABLE_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
// session is not freshly second-factor-verified.
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
const rl = await checkRateLimit(
`mfa:disable:${user.id}`,
DISABLE_RATE_LIMIT,
DISABLE_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(400, "Invalid JSON body");
}
const parsed = mfaDisableSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const factor = await verifyMfaFactor(
user,
parsed.data.method,
parsed.data.code,
);
if (!factor.ok) {
await auditLog("auth.mfa.failed", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
stage: "disable",
method: parsed.data.method,
replay: factor.replay,
},
});
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
throw new HttpError(401, "Invalid code");
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: {
totpSecretEncrypted: null,
totpConfirmedAt: null,
totpLastStep: null,
},
});
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
});
await auditLog("auth.mfa.disabled", {
userId: user.id,
ipAddress: getClientIp(req),
details: { factor: parsed.data.method },
});
annotate({ action: { name: "auth.mfa.disabled" } });
return apiSuccess({ enabled: false });
});
@@ -0,0 +1,60 @@
/**
* POST /api/auth/me/mfa/recovery-codes/regenerate
*
* Issue a fresh recovery-code batch, invalidating the entire prior set in one
* transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is
* a sensitive action; Bearer can never satisfy the gate. The new codes are
* returned **once**.
*/
import {
apiHandler,
requireFreshMfa,
HttpError,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { regenerateRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
export const dynamic = "force-dynamic";
const REGEN_RATE_LIMIT = 5;
const REGEN_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
// Defence in depth — regeneration only makes sense for an active factor.
if (!user.totpConfirmedAt) {
throw new HttpError(409, "No second factor is active");
}
const rl = await checkRateLimit(
`mfa:recovery-regen:${user.id}`,
REGEN_RATE_LIMIT,
REGEN_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
const recoveryCodes = await regenerateRecoveryCodes(user.id);
await auditLog("auth.mfa.recovery_regenerated", {
userId: user.id,
ipAddress: getClientIp(req),
details: { count: recoveryCodes.length },
});
annotate({ action: { name: "auth.mfa.recovery_regenerated" } });
return apiSuccess({
recoveryCodes,
recoveryCodesRemaining: recoveryCodes.length,
});
});
@@ -0,0 +1,124 @@
/**
* POST /api/auth/me/mfa/totp/confirm
*
* Finish TOTP enrollment. Verifies a code against the pending secret; on
* success it promotes pending → active (`totpConfirmedAt = now()`), records
* the accepted time-step (replay guard), generates + persists the Argon2id-
* hashed recovery codes, and returns the codes **once** for the user to save.
*
* The completing session is also stamped `mfaVerifiedAt = now()` so the user
* can immediately manage the freshly-enabled factor (disable / regenerate
* codes) without an extra step-up round-trip.
*
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA.
*/
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyTotp } from "@/lib/auth/mfa/totp";
import {
generateRecoveryCodes,
persistRecoveryCodes,
} from "@/lib/auth/mfa/recovery-codes";
import { totpConfirmSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
const CONFIRM_RATE_LIMIT = 10;
const CONFIRM_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
const { user, session } = await requireCookieAuth();
const rl = await checkRateLimit(
`mfa:confirm:${user.id}`,
CONFIRM_RATE_LIMIT,
CONFIRM_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
if (user.totpConfirmedAt) {
throw new HttpError(409, "A second factor is already active");
}
if (!user.totpSecretEncrypted) {
throw new HttpError(409, "Start enrollment before confirming");
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(400, "Invalid JSON body");
}
const parsed = totpConfirmSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid code", 422);
}
// Decrypt is fail-closed — a missing/rotated key throws rather than
// confirming against junk.
const secret = decrypt(user.totpSecretEncrypted);
const result = verifyTotp(
secret,
parsed.data.code,
user.totpLastStep === null ? null : Number(user.totpLastStep),
);
if (!result.valid) {
await auditLog("auth.mfa.failed", {
userId: user.id,
ipAddress: getClientIp(req),
details: { stage: "confirm", replay: result.replay },
});
annotate({
action: { name: "auth.mfa.totp.confirm.invalid" },
meta: { replay: result.replay },
});
throw new HttpError(401, "Invalid code");
}
const recoveryCodes = generateRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: {
totpConfirmedAt: new Date(),
totpLastStep: BigInt(result.step as number),
},
});
// A fresh enrollment starts from a clean recovery set.
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
await persistRecoveryCodes(tx, user.id, recoveryCodes);
// Stamp the completing session so the user can immediately manage the
// factor under step-up without re-verifying.
await tx.session.updateMany({
where: { id: session.id },
data: { mfaVerifiedAt: new Date() },
});
});
await auditLog("auth.mfa.enabled", {
userId: user.id,
ipAddress: getClientIp(req),
details: { factor: "totp" },
});
annotate({ action: { name: "auth.mfa.enabled" } });
// `recoveryCodes` is redaction-denylisted (`/recovery.?code/i`).
return apiSuccess({
enabled: true,
recoveryCodes,
recoveryCodesRemaining: recoveryCodes.length,
});
});
@@ -0,0 +1,76 @@
/**
* POST /api/auth/me/mfa/totp/setup
*
* Begin TOTP enrollment. Generates a 160-bit secret, stores it **encrypted**
* (AES-256-GCM, the same at-rest envelope as every other credential), and
* returns the `otpauth://` URI + the raw Base32 secret so the client can
* render the QR and offer manual entry. The secret is **pending** — MFA is
* not active until `/confirm` verifies a code, so `totpConfirmedAt` stays
* null here.
*
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The
* recovery-code batch is issued at `/confirm` (after the factor is proven),
* not here, so an abandoned setup never persists codes.
*/
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
export const dynamic = "force-dynamic";
const SETUP_RATE_LIMIT = 5;
const SETUP_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireCookieAuth();
const rl = await checkRateLimit(
`mfa:setup:${user.id}`,
SETUP_RATE_LIMIT,
SETUP_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
// An already-active factor must be disabled (step-up gated) before a new
// secret can be enrolled — re-running setup must not silently rotate the
// live secret out from under the user's authenticator.
if (user.totpConfirmedAt) {
annotate({ action: { name: "auth.mfa.totp.setup.already_active" } });
throw new HttpError(409, "A second factor is already active");
}
const secret = generateTotpSecret();
const account = user.email ?? user.username;
const otpauthUri = buildOtpauthUri(secret, account);
// Store the pending secret encrypted; leave `totpConfirmedAt` null.
await prisma.user.update({
where: { id: user.id },
data: {
totpSecretEncrypted: encrypt(secret),
// A re-setup resets the replay counter — the new secret starts fresh.
totpLastStep: null,
},
});
await auditLog("auth.mfa.totp.setup", {
userId: user.id,
ipAddress: getClientIp(req),
});
annotate({ action: { name: "auth.mfa.totp.setup" } });
// `totpSecret` / `otpauthUri` are redaction-denylisted (`/totp/i`,
// `/otp/i`, `/secret/i`) so they never surface in a wide-event excerpt.
return apiSuccess({ otpauthUri, totpSecret: secret });
});
+139
View File
@@ -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,
});
});