feat(auth): branch login through a second-factor challenge

When a password is accepted for an account with a confirmed TOTP factor, the
login route no longer mints a session or token. It creates a single-use,
~5-minute MfaChallenge (the opaque ticket is stored only as a hash) and
returns HTTP 200 with data:null, error:null and meta.mfaRequired + mfaTicket +
methods. The client completes the login at POST /api/auth/mfa/verify with the
ticket + a TOTP or recovery code, receiving the exact same token bundle the
password path issues, with the session stamped second-factor-verified.

- The ticket is claimed once, atomically, only after the factor verifies, so
  two concurrent submissions can mint at most one session; no session ever
  exists before factor two.
- Wrong codes increment the ticket attempt counter and burn it at the cap
  (throttle, not an account lock); the anonymous verify surface is also
  rate-limited per IP.
- meta.mfaRequired is returned only after a valid password and the
  invalid-credentials response is identical regardless of MFA state, so an
  attacker without the password cannot enumerate MFA.
- The session/token tail is extracted to login-response.ts and shared by both
  the password path and the verify path, keeping the transport contract
  identical. OpenAPI registers the verify endpoint, the MFA management
  endpoints, and the mfaRequired login variant.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 19:56:30 +02:00
1 parent b446a66753
commit 6b3d3fd840
10 files changed
+1508 -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",
});
});
+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,
});
});