mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
135bb04f0d
commit
c8bef61344
22 files changed
+431
-8
No files matched your search
+12
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user