feat(security): let an operator require a second factor

Wire the previously-unused mfaEnforced flag into a real policy. An admin can
require a second factor instance-wide (an AppSettings toggle in the General
section) or per user (a toggle in user management); the effective requirement is
the OR of the two. When required and the account has no active factor — no
confirmed TOTP, no registered security key, and no primary passkey — the user is
walled behind a forced-enrollment interstitial after sign-in, with only the
security settings and sign-out reachable until a factor is enrolled.

The gate is surfaced like the onboarding gate: a hint cookie the proxy reads
without a DB round-trip, recomputed from the database on every sign-in and on
/api/auth/me so a tampered cookie self-corrects. Nobody is locked out — the
enrollment surface stays reachable, and toggling the policy off (or the
disable-mfa CLI) is the operator's recovery path.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 21:49:53 +02:00
1 parent 135bb04f0d
commit c8bef61344
22 files changed
+431 -8

No files matched your search

+17
View File
@@ -241,6 +241,23 @@ export function proxy(request: NextRequest) {
if (onboardingPending && !isOnboardingSurface) {
return NextResponse.redirect(new URL("/onboarding", request.url));
}
// v1.23 — admin-enforced MFA forced-enrollment gate. When the operator
// requires a second factor and the account has none, `/api/auth/me` (and
// every auth surface) sets `hl_mfa_enroll=required`; the proxy reads it
// without a DB round-trip — mirroring the onboarding hint — and walls the
// app behind the enrollment surface. Only `/enroll-mfa` and
// `/settings/security` stay reachable so the user can actually enrol;
// logout + the enrollment APIs are `/api/*` and never page-gated here.
// Onboarding takes precedence (the branch above already returned), so a
// brand-new account finishes onboarding first.
const mfaEnrollRequired =
request.cookies.get("hl_mfa_enroll")?.value === "required";
const isMfaEnrollSurface =
pathname === "/enroll-mfa" || pathname.startsWith("/settings/security");
if (mfaEnrollRequired && !isMfaEnrollSurface && !isOnboardingSurface) {
return NextResponse.redirect(new URL("/enroll-mfa", request.url));
}
}
// Generate or propagate x-request-id for request correlation