mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user