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

+12 -1
View File
@@ -278,6 +278,11 @@
"useAuthenticator": "Authenticator-App verwenden",
"backToLogin": "Zurück zur Anmeldung",
"rememberDevice": "Diesem Gerät 30 Tage vertrauen"
},
"enrollMfa": {
"title": "Zweiten Faktor einrichten",
"body": "Dein Administrator verlangt einen zweiten Faktor für dieses Konto. Füge eine Authenticator-App oder einen Sicherheitsschlüssel hinzu, um fortzufahren.",
"signOut": "Abmelden"
}
},
"dashboard": {
@@ -5627,7 +5632,13 @@
}
},
"carrier": "Anbieter",
"carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar"
"carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar",
"mfaRequired": "Zweiten Faktor verlangen",
"mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet.",
"mfaEnforcedOn": "MFA an",
"mfaEnforcedOff": "MFA aus",
"mfaEnforcedOnHint": "Für diesen Nutzer ist ein zweiter Faktor erforderlich. Klicken, um die Anforderung aufzuheben.",
"mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen."
},
"achievements": {
"title": "Erfolge",
+12 -1
View File
@@ -278,6 +278,11 @@
"useAuthenticator": "Use your authenticator app",
"backToLogin": "Back to login",
"rememberDevice": "Trust this device for 30 days"
},
"enrollMfa": {
"title": "Set up a second factor",
"body": "Your administrator requires a second factor on this account. Add an authenticator app or a security key to continue.",
"signOut": "Sign out"
}
},
"dashboard": {
@@ -5627,7 +5632,13 @@
}
},
"carrier": "Carrier",
"carrierUnavailableGeoFallback": "{location} — carrier unavailable"
"carrierUnavailableGeoFallback": "{location} — carrier unavailable",
"mfaRequired": "Require a second factor",
"mfaRequiredDescription": "Every account without an active second factor is sent to enrollment after sign-in.",
"mfaEnforcedOn": "MFA on",
"mfaEnforcedOff": "MFA off",
"mfaEnforcedOnHint": "A second factor is required for this user. Click to stop requiring it.",
"mfaEnforcedOffHint": "Require a second factor for this user."
},
"achievements": {
"title": "Achievements",
+12 -1
View File
@@ -278,6 +278,11 @@
"useAuthenticator": "Usar tu aplicación de autenticación",
"backToLogin": "Volver al inicio de sesión",
"rememberDevice": "Confiar en este dispositivo durante 30 días"
},
"enrollMfa": {
"title": "Configurar un segundo factor",
"body": "Tu administrador exige un segundo factor en esta cuenta. Añade una app de autenticación o una llave de seguridad para continuar.",
"signOut": "Cerrar sesión"
}
},
"dashboard": {
@@ -5627,7 +5632,13 @@
}
},
"carrier": "Operador",
"carrierUnavailableGeoFallback": "{location} — operador no disponible"
"carrierUnavailableGeoFallback": "{location} — operador no disponible",
"mfaRequired": "Exigir un segundo factor",
"mfaRequiredDescription": "Toda cuenta sin un segundo factor activo se dirige a la configuración tras iniciar sesión.",
"mfaEnforcedOn": "MFA activado",
"mfaEnforcedOff": "MFA desactivado",
"mfaEnforcedOnHint": "Este usuario debe usar un segundo factor. Haz clic para dejar de exigirlo.",
"mfaEnforcedOffHint": "Exigir un segundo factor a este usuario."
},
"achievements": {
"title": "Logros",
+12 -1
View File
@@ -278,6 +278,11 @@
"useAuthenticator": "Utiliser votre application d'authentification",
"backToLogin": "Retour à la connexion",
"rememberDevice": "Faire confiance à cet appareil pendant 30 jours"
},
"enrollMfa": {
"title": "Configurer un second facteur",
"body": "Votre administrateur exige un second facteur sur ce compte. Ajoutez une application d'authentification ou une clé de sécurité pour continuer.",
"signOut": "Se déconnecter"
}
},
"dashboard": {
@@ -5627,7 +5632,13 @@
}
},
"carrier": "Opérateur",
"carrierUnavailableGeoFallback": "{location} — opérateur indisponible"
"carrierUnavailableGeoFallback": "{location} — opérateur indisponible",
"mfaRequired": "Exiger un second facteur",
"mfaRequiredDescription": "Tout compte sans second facteur actif est dirigé vers la configuration après la connexion.",
"mfaEnforcedOn": "MFA activé",
"mfaEnforcedOff": "MFA désactivé",
"mfaEnforcedOnHint": "Un second facteur est requis pour cet utilisateur. Cliquez pour ne plus l'exiger.",
"mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur."
},
"achievements": {
"title": "Réussites",
+12 -1
View File
@@ -278,6 +278,11 @@
"useAuthenticator": "Usa la tua app di autenticazione",
"backToLogin": "Torna all'accesso",
"rememberDevice": "Considera attendibile questo dispositivo per 30 giorni"
},
"enrollMfa": {
"title": "Configura un secondo fattore",
"body": "Il tuo amministratore richiede un secondo fattore su questo account. Aggiungi un'app di autenticazione o una chiave di sicurezza per continuare.",
"signOut": "Esci"
}
},
"dashboard": {
@@ -5627,7 +5632,13 @@
}
},
"carrier": "Operatore",
"carrierUnavailableGeoFallback": "{location} — operatore non disponibile"
"carrierUnavailableGeoFallback": "{location} — operatore non disponibile",
"mfaRequired": "Richiedi un secondo fattore",
"mfaRequiredDescription": "Ogni account senza un secondo fattore attivo viene indirizzato alla configurazione dopo l'accesso.",
"mfaEnforcedOn": "MFA attivo",
"mfaEnforcedOff": "MFA disattivato",
"mfaEnforcedOnHint": "Per questo utente è richiesto un secondo fattore. Clicca per non richiederlo più.",
"mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente."
},
"achievements": {
"title": "Obiettivi",
+12 -1
View File
@@ -278,6 +278,11 @@
"useAuthenticator": "Użyj aplikacji uwierzytelniającej",
"backToLogin": "Powrót do logowania",
"rememberDevice": "Zaufaj temu urządzeniu na 30 dni"
},
"enrollMfa": {
"title": "Skonfiguruj drugi składnik",
"body": "Administrator wymaga drugiego składnika na tym koncie. Dodaj aplikację uwierzytelniającą lub klucz bezpieczeństwa, aby kontynuować.",
"signOut": "Wyloguj się"
}
},
"dashboard": {
@@ -5627,7 +5632,13 @@
}
},
"carrier": "Operator",
"carrierUnavailableGeoFallback": "{location} — operator niedostępny"
"carrierUnavailableGeoFallback": "{location} — operator niedostępny",
"mfaRequired": "Wymagaj drugiego składnika",
"mfaRequiredDescription": "Każde konto bez aktywnego drugiego składnika jest kierowane do konfiguracji po zalogowaniu.",
"mfaEnforcedOn": "MFA wł.",
"mfaEnforcedOff": "MFA wył.",
"mfaEnforcedOnHint": "Ten użytkownik musi używać drugiego składnika. Kliknij, aby przestać go wymagać.",
"mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika."
},
"achievements": {
"title": "Osiągnięcia",
+3
View File
@@ -29,6 +29,7 @@ export const GET = apiHandler(async () => {
return apiSuccess({
registrationEnabled: settings?.registrationEnabled ?? true,
mfaRequired: settings?.mfaRequired ?? false,
defaultLocale: settings?.defaultLocale ?? "de",
telegramGlobal: settings?.telegramGlobal ?? true,
ntfyGlobal: settings?.ntfyGlobal ?? true,
@@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Boolean fields — direct mapping
const booleanFields = [
"registrationEnabled",
"mfaRequired",
"telegramGlobal",
"ntfyGlobal",
"webPushGlobal",
@@ -216,6 +218,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiSuccess({
registrationEnabled: settings.registrationEnabled,
mfaRequired: settings.mfaRequired,
defaultLocale: settings.defaultLocale,
telegramGlobal: settings.telegramGlobal,
ntfyGlobal: settings.ntfyGlobal,
+7
View File
@@ -16,6 +16,9 @@ const updateUserSchema = z.object({
username: z.string().min(3).max(30).optional(),
email: z.string().email().nullable().optional(),
role: z.enum(["ADMIN", "USER"]).optional(),
// v1.23 — per-user "require a second factor" override. Effective requirement
// is the OR of this and the instance-wide AppSettings.mfaRequired policy.
mfaEnforced: z.boolean().optional(),
});
export const PUT = apiHandler(
@@ -64,6 +67,9 @@ export const PUT = apiHandler(
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
...(parsed.data.mfaEnforced !== undefined && {
mfaEnforced: parsed.data.mfaEnforced,
}),
},
select: {
id: true,
@@ -71,6 +77,7 @@ export const PUT = apiHandler(
email: true,
role: true,
createdAt: true,
mfaEnforced: true,
},
});
@@ -111,6 +111,7 @@ describe("GET /api/admin/users", () => {
email: true,
role: true,
createdAt: true,
mfaEnforced: true,
_count: { select: { passkeys: true } },
});
expect(args.select).not.toHaveProperty("passwordHash");
+4
View File
@@ -16,6 +16,7 @@ export const GET = apiHandler(async () => {
email: true,
role: true,
createdAt: true,
mfaEnforced: true,
_count: { select: { passkeys: true } },
},
orderBy: { createdAt: "asc" },
@@ -28,6 +29,9 @@ export const GET = apiHandler(async () => {
email: u.email,
role: u.role,
createdAt: u.createdAt,
// v1.23 — per-user "require a second factor" override. The effective
// requirement is the OR of this and the instance-wide policy.
mfaEnforced: u.mfaEnforced,
passkeyCount: u._count.passkeys,
})),
);
@@ -30,6 +30,7 @@ import {
persistRecoveryCodes,
} from "@/lib/auth/mfa/recovery-codes";
import { totpConfirmSchema } from "@/lib/validations/mfa";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
export const dynamic = "force-dynamic";
@@ -113,6 +114,10 @@ export const POST = apiHandler(async (req: Request) => {
});
});
// v1.23 — the account now has an active second factor, so any
// admin-enforced forced-enrollment redirect must clear immediately.
await setMfaEnrollCookie(false);
await auditLog("auth.mfa.enabled", {
userId: user.id,
ipAddress: getClientIp(req),
@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/mfa-enrollment", () => ({
setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
@@ -18,6 +18,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
export const dynamic = "force-dynamic";
@@ -59,6 +60,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
});
// v1.23 — a registered security key satisfies an admin-enforced MFA policy,
// so clear any forced-enrollment redirect immediately.
await setMfaEnrollCookie(false);
await auditLog("auth.mfa.webauthn.register", {
userId: user.id,
ipAddress: getClientIp(request),
+10 -1
View File
@@ -2,6 +2,7 @@ import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
@@ -26,8 +27,16 @@ export const GET = apiHandler(async () => {
// cycle-tracking gate; no row is forced, a NULL toggle derives from
// gender) is a Postgres round-trip. Running them sequentially added
// the cookie hop to every /me — and /me sits on every app boot path.
const [, cycleProfile, modules, moduleAvailability] = await Promise.all([
const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { ShieldAlert, LogOut, Loader2 } from "lucide-react";
import { Logo } from "@/components/ui/logo";
import { Button } from "@/components/ui/button";
import { SecuritySection } from "@/components/settings/security-section";
import { useLogout } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.23 — forced second-factor enrollment interstitial.
*
* The proxy redirects here whenever the operator requires MFA and the account
* has no active second factor (the `hl_mfa_enroll=required` hint cookie). It
* reuses the security hub's enrollment cards (TOTP + security keys); the moment
* a factor is confirmed the enrollment route clears the hint cookie and the
* next navigation lands wherever the user was headed. A sign-out button keeps
* the only other escape — the user is never trapped, and the operator's CLI
* (`scripts/disable-mfa.ts`) / instance toggle is the recovery of last resort.
*/
export default function EnrollMfaPage() {
const { t } = useTranslations();
const logout = useLogout();
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-6 px-4 py-10">
<div className="flex flex-col items-center gap-3 text-center">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Logo className="text-primary" size={28} />
</div>
<div className="space-y-2">
<div className="text-foreground inline-flex items-center gap-2 text-lg font-semibold">
<ShieldAlert className="text-primary h-5 w-5" aria-hidden="true" />
{t("auth.enrollMfa.title")}
</div>
<p className="text-muted-foreground text-sm">
{t("auth.enrollMfa.body")}
</p>
</div>
</div>
<SecuritySection />
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => logout.mutate()}
disabled={logout.isPending}
>
{logout.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<LogOut className="h-4 w-4" />
)}
{t("auth.enrollMfa.signOut")}
</Button>
</div>
</div>
);
}
+4
View File
@@ -26,6 +26,8 @@ export interface AdminUser {
email: string | null;
role: string;
createdAt: string;
// v1.23 — per-user "require a second factor" override.
mfaEnforced: boolean;
passkeyCount: number;
}
@@ -65,6 +67,8 @@ export interface SystemStatus {
export interface AdminSettings {
registrationEnabled: boolean;
// v1.23 — instance-wide "require a second factor" policy.
mfaRequired: boolean;
defaultLocale: string;
telegramGlobal: boolean;
ntfyGlobal: boolean;
@@ -43,6 +43,19 @@ export function GeneralSettingsSection() {
disabled={updateSettings.isPending}
/>
{/* v1.23 — instance-wide "require a second factor" policy. Every
account without an active factor is sent to forced enrollment after
sign-in. */}
<SettingsToggle
label={t("admin.mfaRequired")}
description={t("admin.mfaRequiredDescription")}
checked={settings?.mfaRequired ?? false}
onCheckedChange={(checked) =>
updateSettings.mutate({ mfaRequired: checked })
}
disabled={updateSettings.isPending}
/>
{/* v1.4.27 MB7 / CF-56 — match the SettingsToggle stacking
contract: stack on `<sm`, side-by-side on `sm+`. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -2,7 +2,15 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, LogOut, Pencil, Shield, Users } from "lucide-react";
import {
KeyRound,
Loader2,
LogOut,
Pencil,
Shield,
ShieldCheck,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -177,6 +185,31 @@ export function UserManagementSection() {
<Shield className="h-3 w-3" aria-hidden="true" />
{u.role === "ADMIN" ? t("admin.toUser") : t("admin.toAdmin")}
</Button>
<Button
variant="ghost"
size="sm"
className={`min-h-11 min-w-11 px-3 text-xs ${u.mfaEnforced ? "text-dracula-green" : ""}`}
onClick={() =>
updateUser.mutate({
id: u.id,
data: { mfaEnforced: !u.mfaEnforced },
})
}
title={
u.mfaEnforced
? t("admin.mfaEnforcedOnHint")
: t("admin.mfaEnforcedOffHint")
}
aria-label={
u.mfaEnforced
? t("admin.mfaEnforcedOnHint")
: t("admin.mfaEnforcedOffHint")
}
aria-pressed={u.mfaEnforced}
>
<ShieldCheck className="h-3 w-3" aria-hidden="true" />
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
</Button>
<Button
variant="ghost"
size="sm"
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
passkey: { count: vi.fn() },
},
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
const cookieStore = { set: vi.fn(), delete: vi.fn(), get: vi.fn() };
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => cookieStore),
}));
import { resolveMfaEnrollmentRequired } from "../mfa-enrollment";
import { prisma } from "@/lib/db";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
mfaRequired: false,
} as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
});
describe("resolveMfaEnrollmentRequired", () => {
it("returns false (and skips all DB reads) for an account with confirmed TOTP", async () => {
const required = await resolveMfaEnrollmentRequired("u1", {
totpConfirmedAt: new Date(),
mfaEnforced: true,
});
expect(required).toBe(false);
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
expect(prisma.webauthnMfaCredential.count).not.toHaveBeenCalled();
});
it("returns false when no policy applies, even for a factor-less account", async () => {
const required = await resolveMfaEnrollmentRequired("u1", {
totpConfirmedAt: null,
mfaEnforced: false,
});
expect(required).toBe(false);
// Policy off short-circuits before counting factors.
expect(prisma.webauthnMfaCredential.count).not.toHaveBeenCalled();
});
it("forces enrollment under the instance policy for a factor-less account", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
mfaRequired: true,
} as never);
const required = await resolveMfaEnrollmentRequired("u1", {
totpConfirmedAt: null,
mfaEnforced: false,
});
expect(required).toBe(true);
});
it("forces enrollment under a per-user override even with the instance policy off", async () => {
const required = await resolveMfaEnrollmentRequired("u1", {
totpConfirmedAt: null,
mfaEnforced: true,
});
expect(required).toBe(true);
});
it("lets a registered security key satisfy the policy", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
mfaRequired: true,
} as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
const required = await resolveMfaEnrollmentRequired("u1", {
totpConfirmedAt: null,
mfaEnforced: false,
});
expect(required).toBe(false);
});
it("lets a primary passkey satisfy the policy", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
mfaRequired: true,
} as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
const required = await resolveMfaEnrollmentRequired("u1", {
totpConfirmedAt: null,
mfaEnforced: false,
});
expect(required).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Admin-enforced MFA policy — the forced-enrollment gate.
*
* The effective per-user requirement is the OR of an instance-wide policy
* (`AppSettings.mfaRequired`) and a per-user override (`User.mfaEnforced`).
* When MFA is required AND the account has NO active second factor (no
* confirmed TOTP and no registered security key), the user is sent through a
* forced-enrollment interstitial after sign-in until they enrol one.
*
* The gate is surfaced exactly like the onboarding gate: a non-httpOnly UX-hint
* cookie (`hl_mfa_enroll`) the proxy reads without a DB round-trip. It is a
* server-authoritative nudge, not a cryptographic wall — every issuing auth
* surface and `/api/auth/me` recompute it from the DB, so a locally edited
* cookie is corrected on the next data fetch. The user can always reach
* `/settings/security` (and the enrollment APIs) to enrol, so nobody is locked
* out; the operator/CLI escape hatch (`scripts/disable-mfa.ts`, toggling the
* policy off) is the recovery path of last resort.
*/
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/** UX-hint cookie the proxy reads to redirect into the enrollment surface. */
export const MFA_ENROLL_COOKIE = "hl_mfa_enroll";
const MFA_ENROLL_PENDING = "required";
/** The session cookie max-age, mirrored so the hint expires with the session. */
const COOKIE_MAX_AGE_S = 30 * 24 * 60 * 60;
interface MfaPolicyUser {
totpConfirmedAt: Date | null;
mfaEnforced: boolean;
}
/**
* Resolve whether the account must enrol a second factor before using the app.
* Cheap by construction: an account that already has TOTP short-circuits with
* zero queries; an instance with the policy off resolves with one small read.
*/
export async function resolveMfaEnrollmentRequired(
userId: string,
user: MfaPolicyUser,
): Promise<boolean> {
// Already has a confirmed TOTP secret → enrolled, nothing required.
if (user.totpConfirmedAt) return false;
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { mfaRequired: true },
});
const policyApplies = Boolean(settings?.mfaRequired) || user.mfaEnforced;
if (!policyApplies) return false;
// Policy applies and there is no TOTP. Any of three credentials satisfies the
// requirement: a registered security key (the MFA WebAuthn store, matching
// `requireFreshMfa`'s either-factor rule) OR a primary passkey — a
// passwordless passkey login is itself a phishing-resistant possession factor
// and stamps `mfaVerifiedAt` (M-review M1), so a passkey user is "enrolled"
// for enforcement purposes and is not nagged to add a second factor on top.
const [webauthnKeyCount, passkeyCount] = await Promise.all([
prisma.webauthnMfaCredential.count({ where: { userId } }),
prisma.passkey.count({ where: { userId } }),
]);
return webauthnKeyCount === 0 && passkeyCount === 0;
}
/** Set or clear the forced-enrollment UX-hint cookie. */
export async function setMfaEnrollCookie(required: boolean): Promise<void> {
const cookieStore = await cookies();
if (required) {
cookieStore.set(MFA_ENROLL_COOKIE, MFA_ENROLL_PENDING, {
// UX hint only — read by the proxy to redirect; the real check is the
// server-side recompute on every /api/auth/me + auth surface.
httpOnly: false,
secure: shouldEmitSecureCookie(),
sameSite: "strict",
maxAge: COOKIE_MAX_AGE_S,
path: "/",
});
} else {
cookieStore.delete(MFA_ENROLL_COOKIE);
}
}
/** Recompute the requirement for a user and sync the hint cookie to it. */
export async function syncMfaEnrollCookie(
userId: string,
user: MfaPolicyUser,
): Promise<void> {
const required = await resolveMfaEnrollmentRequired(userId, user);
await setMfaEnrollCookie(required);
}
+2
View File
@@ -8,6 +8,8 @@ import { isPublicUrl } from "@/lib/validations/notifications";
export const adminSettingsSchema = z
.object({
registrationEnabled: z.boolean().optional(),
// v1.23 — instance-wide "require a second factor" policy.
mfaRequired: z.boolean().optional(),
defaultLocale: z.enum(["de", "en"]).optional(),
telegramGlobal: z.boolean().optional(),
ntfyGlobal: z.boolean().optional(),
+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