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

+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"