diff --git a/messages/de.json b/messages/de.json index 5fe384c84..233f0c8ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 52b7f58fd..81ce03e05 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 940d98362..109649d9d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index aff7fe265..846e7898b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index a24307993..5cd11eb56 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 15b46d6a6..586d1281b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 2dc0acb19..51ad16dcc 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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, diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index d682f316f..7270e4b93 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -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, }, }); diff --git a/src/app/api/admin/users/__tests__/route.test.ts b/src/app/api/admin/users/__tests__/route.test.ts index ed756306a..4e7a8bd1b 100644 --- a/src/app/api/admin/users/__tests__/route.test.ts +++ b/src/app/api/admin/users/__tests__/route.test.ts @@ -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"); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 573891c7f..60d0097ba 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -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, })), ); diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 840e48fa7..12bf3b78e 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -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), diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts index 0b2ad01f6..37551d36d 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts @@ -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(), diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index 5c4cad35e..8a9e0c5d7 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -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), diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 4ef9ebe53..615a47f43 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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 }, diff --git a/src/app/enroll-mfa/page.tsx b/src/app/enroll-mfa/page.tsx new file mode 100644 index 000000000..66c0ccb66 --- /dev/null +++ b/src/app/enroll-mfa/page.tsx @@ -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 ( +
+
+
+ +
+
+
+
+

+ {t("auth.enrollMfa.body")} +

+
+
+ + + +
+ +
+
+ ); +} diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 007413756..da0481bb3 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -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; diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 85244075c..75d35bfe9 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -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. */} + + updateSettings.mutate({ mfaRequired: checked }) + } + disabled={updateSettings.isPending} + /> + {/* v1.4.27 MB7 / CF-56 — match the SettingsToggle stacking contract: stack on ` diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index f306ab842..320618621 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -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() {