fix(auth): close the MFA policy lockout and add self-service enrollment (#531)

Fixes #529 (opened investigating #515).

Setting MFA policy to "required"/"admins only" saved regardless of whether the mfa enterprise feature was licensed, and there was no enrollment UI at all, so any instance that flipped the toggle locked every unenrolled user out with no way back in. The login page and Settings save also both collapsed the resulting error into a generic message, hiding the real reason.

- Reject saving mfaPolicy to admins_only/required server-side unless mfa is licensed
- Surface the specific server error on login and on a failed settings save instead of a generic fallback
- Add a self-service two-factor authentication enrollment flow (QR code, manual entry, recovery codes, verify, disable) so a licensed admin can actually satisfy the policy before it's enforced
- Fix a pending-enrollment dead end, silent error swallowing in verify/disable, and a silent clipboard-copy failure on the recovery codes screen
- Add the integration test that actually proves the fix: a real login attempt returns 403 MFA_ENROLLMENT_REQUIRED
This commit is contained in:
SnapOtter
2026-07-16 18:07:27 +08:00
committed by GitHub
parent d88999e7a9
commit 190d4c2a00
34 changed files with 1624 additions and 19 deletions
@@ -45,6 +45,7 @@ import { OtterLogo } from "../common/otter-logo";
import { AdminInstallFeedbackCard } from "../feedback/admin-install-feedback-card";
import { FeedbackDialog } from "../feedback/feedback-dialog";
import { AiFeaturesSection } from "./ai-features-section";
import { TwoFactorSettings } from "./two-factor-settings";
import { UsageSection } from "./usage-section";
interface SettingsDialogProps {
@@ -1068,12 +1069,14 @@ function SecuritySection() {
<p className="text-sm text-muted-foreground">{t.settings.security.loginAttemptLimitNote}</p>
</div>
<TwoFactorSettings />
{hasPermission("settings:write") && <AdminSecuritySettings />}
</div>
);
}
function AdminSecuritySettings() {
export function AdminSecuritySettings() {
const { t } = useTranslation();
const [settings, setSettings] = useState<Record<string, string>>({});
// Snapshot of the last server state; a save sends only the fields this tab changed
@@ -1082,7 +1085,7 @@ function AdminSecuritySettings() {
const originalSettingsRef = useRef<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
@@ -1107,9 +1110,12 @@ function AdminSecuritySettings() {
writableSettings(changedSettings(originalSettingsRef.current, settings)),
);
originalSettingsRef.current = { ...settings };
setSaveMsg(t.settings.security.securitySettingsSaved);
} catch {
setSaveMsg(t.settings.security.securitySettingsFailed);
setSaveMsg({ type: "success", text: t.settings.security.securitySettingsSaved });
} catch (err) {
setSaveMsg({
type: "error",
text: err instanceof Error ? err.message : t.settings.security.securitySettingsFailed,
});
} finally {
setSaving(false);
setTimeout(() => setSaveMsg(null), 3000);
@@ -1341,12 +1347,10 @@ function AdminSecuritySettings() {
<span
className={cn(
"text-sm",
saveMsg === t.settings.security.securitySettingsFailed
? "text-destructive"
: "text-green-600 dark:text-green-400",
saveMsg.type === "error" ? "text-destructive" : "text-green-600 dark:text-green-400",
)}
>
{saveMsg}
{saveMsg.text}
</span>
)}
</div>
@@ -0,0 +1,322 @@
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { apiPost } from "@/lib/api";
import { cn, copyToClipboard } from "@/lib/utils";
type Step = "idle" | "enrolling" | "disabling";
interface EnrollResponse {
uri: string;
recoveryCodes: string[];
}
function parseManualSecret(uri: string): string {
try {
const params = new URL(uri).searchParams;
return params.get("secret") ?? "";
} catch {
return "";
}
}
function QrCode({ uri }: { uri: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const qr = new QRCodeStyling({
width: 200,
height: 200,
data: uri,
margin: 8,
dotsOptions: { type: "square", color: "#000000" },
backgroundOptions: { color: "#ffffff" },
} as never);
const el = containerRef.current;
if (el) {
while (el.firstChild) el.removeChild(el.firstChild);
qr.append(el);
}
}, [uri]);
return (
<div
ref={containerRef}
className="flex items-center justify-center rounded-xl border border-border p-4 bg-white w-fit"
/>
);
}
export function TwoFactorSettings() {
const { t } = useTranslation();
const { totpEnabled: initialEnabled } = useAuth();
const [enabled, setEnabled] = useState(initialEnabled);
const [step, setStep] = useState<Step>("idle");
const [enrollment, setEnrollment] = useState<EnrollResponse | null>(null);
const [code, setCode] = useState("");
const [codesCopied, setCodesCopied] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
useEffect(() => {
setEnabled(initialEnabled);
}, [initialEnabled]);
const resetToIdle = () => {
setStep("idle");
setEnrollment(null);
setCode("");
setCodesCopied(false);
setMessage(null);
};
const handleEnrollStart = async () => {
setSubmitting(true);
setMessage(null);
try {
const res = await apiPost<EnrollResponse>("/auth/mfa/enroll");
setEnrollment(res);
setStep("enrolling");
} catch (err) {
setMessage({
type: "error",
text: err instanceof Error ? err.message : t.settings.security.securitySettingsFailed,
});
} finally {
setSubmitting(false);
}
};
const handleVerify = async () => {
setSubmitting(true);
setMessage(null);
try {
await apiPost("/auth/mfa/verify", { code });
setEnabled(true);
setMessage({ type: "success", text: t.settings.security.twoFactorEnableSuccess });
setStep("idle");
setEnrollment(null);
setCode("");
setCodesCopied(false);
} catch (err) {
setMessage({
type: "error",
text: err instanceof Error ? err.message : t.auth.mfaInvalidCode,
});
} finally {
setSubmitting(false);
}
};
const handleDisable = async () => {
setSubmitting(true);
setMessage(null);
try {
await apiPost("/auth/mfa/disable", { code });
setEnabled(false);
setMessage({ type: "success", text: t.settings.security.twoFactorDisableSuccess });
setStep("idle");
setCode("");
} catch (err) {
setMessage({
type: "error",
text: err instanceof Error ? err.message : t.auth.mfaInvalidCode,
});
} finally {
setSubmitting(false);
}
};
const handleCopyCodes = async () => {
if (!enrollment) return;
const ok = await copyToClipboard(enrollment.recoveryCodes.join("\n"));
if (ok) {
setCodesCopied(true);
setTimeout(() => setCodesCopied(false), 2000);
} else {
setMessage({ type: "error", text: t.settings.security.twoFactorCopyFailed });
}
};
return (
<div className="border-t border-border pt-6 space-y-4">
<div>
<h4 className="text-sm font-semibold text-foreground">
{t.settings.security.twoFactorHeading}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t.settings.security.twoFactorDescription}
</p>
</div>
{step === "idle" && (
<div className="space-y-3">
<p className="text-sm text-foreground">
{enabled
? t.settings.security.twoFactorEnabledStatus
: t.settings.security.twoFactorDisabledStatus}
</p>
<button
type="button"
onClick={() => (enabled ? setStep("disabling") : handleEnrollStart())}
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{enabled
? t.settings.security.disableTwoFactorButton
: t.settings.security.enableTwoFactorButton}
</button>
</div>
)}
{step === "enrolling" && enrollment && (
<div className="space-y-4">
<p className="text-sm text-foreground">{t.settings.security.twoFactorScanQr}</p>
<QrCode uri={enrollment.uri} />
<div>
<p className="text-xs text-muted-foreground mb-1">
{t.settings.security.twoFactorManualEntry}
</p>
<code className="block text-xs bg-muted rounded-md px-3 py-2 break-all">
{parseManualSecret(enrollment.uri)}
</code>
</div>
<div>
<p className="text-sm font-medium text-foreground">
{t.settings.security.twoFactorRecoveryCodesHeading}
</p>
<p className="text-xs text-muted-foreground mt-1 mb-2">
{t.settings.security.twoFactorRecoveryCodesDescription}
</p>
<div className="grid grid-cols-2 gap-1 rounded-md border border-border p-3 font-mono text-xs">
{enrollment.recoveryCodes.map((rc) => (
<span key={rc}>{rc}</span>
))}
</div>
<button
type="button"
onClick={handleCopyCodes}
className="mt-2 text-xs text-primary hover:underline"
>
{codesCopied
? t.settings.security.twoFactorCodesCopied
: t.settings.security.twoFactorCopyRecoveryCodes}
</button>
</div>
<div>
<label
htmlFor="totp-verify-code"
className="block text-sm font-medium mb-1 text-foreground"
>
{t.settings.security.twoFactorEnterCode}
</label>
<input
id="totp-verify-code"
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/[^0-9]/g, ""))}
placeholder={t.settings.security.twoFactorCodePlaceholder}
maxLength={6}
className="w-40 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground tracking-widest"
/>
</div>
{message && (
<p
className={cn(
"text-sm",
message.type === "error"
? "text-destructive"
: "text-green-600 dark:text-green-400",
)}
>
{message.text}
</p>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleVerify}
disabled={submitting || code.length < 6}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{t.settings.security.twoFactorConfirmButton}
</button>
<button
type="button"
onClick={resetToIdle}
disabled={submitting}
className="px-4 py-2 rounded-lg border border-border text-sm font-medium hover:bg-muted/50 transition-colors disabled:opacity-50"
>
{t.settings.security.twoFactorCancelButton}
</button>
</div>
</div>
)}
{step === "disabling" && (
<div className="space-y-3">
<p className="text-sm text-foreground">{t.settings.security.twoFactorDisablePrompt}</p>
<input
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/[^0-9]/g, ""))}
placeholder={t.settings.security.twoFactorCodePlaceholder}
maxLength={6}
aria-label={t.settings.security.twoFactorEnterCode}
className="w-40 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground tracking-widest"
/>
{message && (
<p
className={cn(
"text-sm",
message.type === "error"
? "text-destructive"
: "text-green-600 dark:text-green-400",
)}
>
{message.text}
</p>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleDisable}
disabled={submitting || code.length < 6}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors disabled:opacity-50"
>
{t.settings.security.disableTwoFactorButton}
</button>
<button
type="button"
onClick={resetToIdle}
disabled={submitting}
className="px-4 py-2 rounded-lg border border-border text-sm font-medium hover:bg-muted/50 transition-colors disabled:opacity-50"
>
{t.settings.security.twoFactorCancelButton}
</button>
</div>
</div>
)}
{step === "idle" && message && (
<p
className={cn(
"text-sm",
message.type === "error" ? "text-destructive" : "text-green-600 dark:text-green-400",
)}
>
{message.text}
</p>
)}
</div>
);
}
+5
View File
@@ -17,6 +17,7 @@ interface AuthState {
ssoEnforced: boolean;
loginMethod: string | null;
hasLocalPassword: boolean;
totpEnabled: boolean;
}
const ANON_ADMIN_PERMISSIONS = [
@@ -52,6 +53,7 @@ export function useAuth() {
ssoEnforced: false,
loginMethod: null,
hasLocalPassword: false,
totpEnabled: false,
});
useEffect(() => {
@@ -79,6 +81,7 @@ export function useAuth() {
ssoEnforced: false,
loginMethod: null,
hasLocalPassword: false,
totpEnabled: false,
});
return;
}
@@ -108,6 +111,7 @@ export function useAuth() {
ssoEnforced: config.ssoEnforced ?? false,
loginMethod: session.user?.loginMethod ?? null,
hasLocalPassword: session.user?.hasLocalPassword ?? false,
totpEnabled: session.user?.totpEnabled === true,
});
} else {
clearToken();
@@ -127,6 +131,7 @@ export function useAuth() {
ssoEnforced: config.ssoEnforced ?? false,
loginMethod: null,
hasLocalPassword: false,
totpEnabled: false,
});
}
} catch {
+6 -1
View File
@@ -168,7 +168,12 @@ export function LoginPage() {
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
setError(t.auth.invalidCredentials);
const failure = await res.json().catch(() => null);
setError(
failure?.code === "MFA_ENROLLMENT_REQUIRED"
? t.auth.mfaEnrollmentRequired
: t.auth.invalidCredentials,
);
return;
}
const data = await res.json();