mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user