feat(auth): add TOTP two-factor authentication with backup codes (#615)

* feat(auth): add TOTP two-factor authentication with backup codes

Adds RFC 6238 time-based one-time password support to every tier,
integrated with the existing password and SSO login paths.

Backend:
- New MfaService wrapping otplib with a plus or minus 1 step tolerance,
  base32 secret generation, and hashed single-use backup codes (bcrypt).
- user_mfa and mfa_used_tokens tables in DatabaseService. The second
  table is a DB-backed replay blacklist, purged on a 60s interval.
- authMiddleware now recognizes an mfa_pending scope. A token carrying
  that scope is rejected on every route except the MFA challenge and
  logout, so no API surface is reachable before the second factor
  clears.
- /api/auth/login issues only a short-lived mfa_pending cookie when the
  user has MFA enrolled. /api/auth/login/mfa consumes that cookie,
  verifies the code (or backup code), and swaps in a real session.
- /api/auth/mfa/* routes for status, enrol/start, enrol/confirm,
  disable, backup-code regenerate, and SSO-bypass opt-in.
- Admin recovery path: POST /api/users/:id/mfa/reset clears the target's
  MFA state, bumps token_version, and writes an audit log entry.
- CLI emergency fallback: backend/src/cli/resetMfa.ts is wired via
  `npm run reset-mfa <username>` and also exported for tests.
- SSO flows (LDAP and OIDC) gate on user_mfa.sso_enforce_mfa before
  issuing a session; default behaviour keeps the SSO path frictionless.
- Per-user lockout after 5 consecutive failed codes (15 min).

Frontend:
- AppStatus gains an mfa-challenge branch driven by /api/auth/status.
- New MfaChallenge screen, MfaEnrollDialog (QR plus manual secret plus
  backup codes), MfaDisableDialog, MfaBackupCodesDialog.
- Account section shows a Two-factor authentication card with enrol,
  regenerate, disable, and the SSO-enforce toggle (shown only when SSO
  providers are configured).
- Users section gains a Reset 2FA action for admins.

Docs:
- New user guide at features/two-factor-authentication.mdx.
- New admin guide at operations/two-factor-admin.mdx.
- SSO page cross-links to the 2FA doc.

* fix(mfa): drop unused TEST_PASSWORD import and stale eslint disable

* fix(mfa): simplify e2e openAccountSettings helper to match working pattern

* fix(mfa): make e2e suite self-contained and always clean up

Test #2 called loginAs() before the MFA challenge step, which waited for
the dashboard indicator that never appears once the previous test enrolled
the user. That timeout skipped the rest of the serial block, including
the disable step, leaving MFA enabled and breaking every later spec.

Two fixes:

- Tests #2 and #3 now navigate directly to the login page instead of
  piggybacking on loginAs, which only handles the password-only path.
- A new afterAll hook unconditionally disables MFA via the API using two
  unused backup codes, so the DB is reset even if a test fails midway.

* fix(e2e): use backup code for mfa recovery to avoid totp replay race

The final recovery step in the backup-code replay test previously
generated a fresh TOTP to sign back in. When the timing landed inside
the same 30-second window that test #2 consumed, the server's replay
blacklist correctly rejected it, producing a ~50% flake rate. Backup
codes are single-use and sidestep the replay window, so the recovery
becomes deterministic.

* fix(e2e): drive mfa disable test through the challenge screen

Test #4 called loginAs after test #3 left MFA enabled, but loginAs
waits for the dashboard indicator and does not handle the challenge
screen, so it timed out. Drive the login manually, satisfy the
challenge with a backup code, and use a backup code for the disable
step too to avoid any TOTP replay-window race against earlier tests
in the serial block.
This commit is contained in:
Anso
2026-04-15 18:45:51 -04:00
committed by GitHub
parent 87263ff357
commit 7d78c9fe22
32 changed files with 2904 additions and 17 deletions
+5
View File
@@ -4,6 +4,7 @@ import { LicenseProvider } from './context/LicenseContext';
import { Login } from './components/Login';
import { Setup } from './components/Setup';
import EditorLayout from './components/EditorLayout';
import { MfaChallenge } from './components/MfaChallenge';
function AppContent() {
const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth();
@@ -20,6 +21,10 @@ function AppContent() {
return <Setup onComplete={completeSetup} />;
}
if (appStatus === 'mfaChallenge') {
return <MfaChallenge />;
}
if (!isAuthenticated) {
return <Login />;
}
+127
View File
@@ -0,0 +1,127 @@
import { useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export function MfaChallenge({
className,
...props
}: React.ComponentPropsWithoutRef<'div'>) {
const { submitMfa, cancelMfa } = useAuth();
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [useBackup, setUseBackup] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
const result = await submitMfa(code, { isBackupCode: useBackup });
if (!result.success) {
const retryNote = result.retryAfter ? ` (try again in ${Math.ceil(result.retryAfter / 60)} min)` : '';
setError((result.error || 'Verification failed') + retryNote);
setCode('');
}
setIsLoading(false);
};
const handleToggleBackup = () => {
setUseBackup((v) => !v);
setCode('');
setError('');
};
return (
<div className={cn('grid min-h-svh md:grid-cols-2', className)} {...props}>
{/* Branding panel (matches Login layout) */}
<div className="relative hidden md:flex flex-col items-center justify-center bg-zinc-950 overflow-hidden">
<div
className="absolute inset-0 opacity-[0.15]"
style={{
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.7) 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
/>
<div className="relative z-10 flex flex-col items-center gap-6 px-12">
<img
src="/sencho-logo-dark.png"
alt="Sencho"
className="w-28 h-28"
draggable={false}
/>
<div className="text-center">
<h1 className="text-4xl font-medium text-foreground tracking-tight">Sencho</h1>
<p className="text-base text-zinc-400 mt-2">Docker Compose Management</p>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-px bg-brand" />
</div>
{/* Form panel */}
<div className="flex flex-col items-center justify-center bg-background px-6 py-12">
<div className="flex items-center gap-2.5 mb-10 md:hidden">
<img src="/sencho-logo-light.png" alt="Sencho" className="w-8 h-8 dark:hidden" draggable={false} />
<img src="/sencho-logo-dark.png" alt="Sencho" className="w-8 h-8 hidden dark:block" draggable={false} />
<span className="text-lg font-semibold tracking-tight">Sencho</span>
</div>
<div className="w-full max-w-sm">
<div className="mb-8">
<h2 className="text-2xl font-bold tracking-tight">Two-factor authentication</h2>
<p className="text-sm text-muted-foreground mt-1">
{useBackup
? 'Enter one of your saved backup codes to continue.'
: 'Open your authenticator app and enter the 6-digit code.'}
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-5">
<div className="grid gap-2">
<Label htmlFor="mfa-code">{useBackup ? 'Backup code' : 'Verification code'}</Label>
<Input
id="mfa-code"
type="text"
inputMode={useBackup ? 'text' : 'numeric'}
autoComplete="one-time-code"
autoFocus
required
maxLength={useBackup ? 12 : 6}
value={code}
onChange={(e) => setCode(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'}
/>
</div>
{error && (
<div className="text-sm text-destructive text-center">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || !code}>
{isLoading ? 'Verifying...' : 'Verify and sign in'}
</Button>
<button
type="button"
className="text-sm text-muted-foreground hover:text-foreground text-center transition-colors"
onClick={handleToggleBackup}
>
{useBackup ? 'Use your authenticator app instead' : 'Use a backup code instead'}
</button>
<button
type="button"
className="text-sm text-muted-foreground/70 hover:text-foreground text-center transition-colors"
onClick={cancelMfa}
>
Cancel and sign out
</button>
</div>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,167 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Copy, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
interface MfaBackupCodesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onRegenerated: () => void;
}
type Step = 'confirm' | 'show';
export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaBackupCodesDialogProps) {
const [step, setStep] = useState<Step>('confirm');
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const resetState = () => {
setStep('confirm');
setCode('');
setError('');
setBackupCodes([]);
};
const handleConfirm = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/backup-codes/regenerate', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ code }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || 'Could not regenerate backup codes');
return;
}
setBackupCodes(data.backupCodes || []);
setStep('show');
} catch (err) {
setError((err as Error)?.message || 'Could not regenerate backup codes');
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(backupCodes.join('\n'));
toast.success('Backup codes copied');
} catch {
toast.error('Could not copy to clipboard');
}
};
const handleDownload = () => {
const blob = new Blob([
'Sencho backup codes\n',
'Each code can be used once. Keep this file somewhere safe.\n\n',
backupCodes.join('\n'),
'\n',
], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sencho-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
const handleFinish = () => {
resetState();
onOpenChange(false);
onRegenerated();
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
if (step === 'show') onRegenerated();
resetState();
}
onOpenChange(next);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'confirm' ? 'Regenerate backup codes' : 'New backup codes'}
</DialogTitle>
<DialogDescription className="sr-only">
Replace your backup codes with a freshly generated set. The previous
set stops working immediately.
</DialogDescription>
</DialogHeader>
{step === 'confirm' && (
<form onSubmit={handleConfirm} className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Your current backup codes will stop working immediately. Confirm with a code from your authenticator app to continue.
</p>
<div className="grid gap-2">
<Label htmlFor="mfa-regen-code">Verification code</Label>
<Input
id="mfa-regen-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
required
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder="123456"
/>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || code.length !== 6}>
{loading ? 'Working...' : 'Regenerate'}
</Button>
</DialogFooter>
</form>
)}
{step === 'show' && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Each code can be used once. Store them somewhere safe; they will not be shown again.
</p>
<div className="grid grid-cols-2 gap-2 rounded-md border border-card-border bg-card p-4 font-mono text-sm tabular-nums tracking-wider shadow-card-bevel">
{backupCodes.map((c) => (
<div key={c} className="text-center">{c}</div>
))}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={handleCopy}>
<Copy className="w-4 h-4 mr-2" strokeWidth={1.5} />
Copy all
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={handleDownload}>
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
Download
</Button>
</div>
<DialogFooter>
<Button type="button" onClick={handleFinish}>I&apos;ve saved these</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
interface MfaDisableDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onDisabled: () => void;
}
export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableDialogProps) {
const [code, setCode] = useState('');
const [useBackup, setUseBackup] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setCode('');
setError('');
setUseBackup(false);
}
}, [open]);
const handleDisable = async () => {
setError('');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/disable', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ code, isBackupCode: useBackup }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || 'Could not disable two-factor authentication');
return;
}
toast.success('Two-factor authentication disabled');
setCode('');
onOpenChange(false);
onDisabled();
} catch (err) {
setError((err as Error)?.message || 'Could not disable two-factor authentication');
} finally {
setLoading(false);
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disable two-factor authentication?</AlertDialogTitle>
<AlertDialogDescription>
Your account will only be protected by a password. Anyone who obtains that password can sign in. Confirm with a current code to continue.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-3">
<div className="grid gap-2">
<Label htmlFor="mfa-disable-code">{useBackup ? 'Backup code' : 'Verification code'}</Label>
<Input
id="mfa-disable-code"
type="text"
inputMode={useBackup ? 'text' : 'numeric'}
autoComplete="one-time-code"
maxLength={useBackup ? 12 : 6}
value={code}
onChange={(e) => setCode(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'}
/>
</div>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
onClick={() => { setUseBackup((v) => !v); setCode(''); setError(''); }}
>
{useBackup ? 'Use your authenticator app instead' : 'Use a backup code instead'}
</button>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<Button
type="button"
variant="ghost"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
disabled={loading || !code}
onClick={handleDisable}
>
{loading ? 'Disabling...' : 'Disable'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Copy, Download, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
interface MfaEnrollDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onEnrolled: () => void;
}
type Step = 'qr' | 'confirm' | 'backup';
/**
* Format a raw base32 secret as groups of 4 characters so it is easier for
* users typing it into authenticator apps manually.
*/
function formatSecret(secret: string): string {
return secret.replace(/(.{4})/g, '$1 ').trim();
}
export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDialogProps) {
const [step, setStep] = useState<Step>('qr');
const [loading, setLoading] = useState(false);
const [otpauthUri, setOtpauthUri] = useState('');
const [secret, setSecret] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
// When the dialog opens, start enrolment so the QR is ready immediately.
useEffect(() => {
if (!open) return;
let cancelled = false;
setStep('qr');
setCode('');
setError('');
setBackupCodes([]);
setShowSecret(false);
setLoading(true);
apiFetch('/auth/mfa/enroll/start', { method: 'POST', localOnly: true })
.then(async (r) => {
const data = await r.json().catch(() => ({}));
if (cancelled) return;
if (!r.ok) {
toast.error(data?.error || 'Failed to start enrolment');
onOpenChange(false);
return;
}
setOtpauthUri(data.otpauthUri);
setSecret(data.secret);
})
.catch((e) => {
if (cancelled) return;
toast.error(e?.message || 'Failed to start enrolment');
onOpenChange(false);
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [open, onOpenChange]);
const handleConfirm = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/enroll/confirm', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ code }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || 'Verification failed');
return;
}
setBackupCodes(data.backupCodes || []);
setStep('backup');
} catch (err) {
setError((err as Error)?.message || 'Verification failed');
} finally {
setLoading(false);
}
};
const handleCopySecret = async () => {
try {
await navigator.clipboard.writeText(secret);
toast.success('Secret copied to clipboard');
} catch {
toast.error('Could not copy to clipboard');
}
};
const handleCopyBackupCodes = async () => {
try {
await navigator.clipboard.writeText(backupCodes.join('\n'));
toast.success('Backup codes copied');
} catch {
toast.error('Could not copy to clipboard');
}
};
const handleDownloadBackupCodes = () => {
const blob = new Blob([
'Sencho backup codes\n',
'Each code can be used once. Keep this file somewhere safe.\n\n',
backupCodes.join('\n'),
'\n',
], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sencho-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
const handleFinish = () => {
onOpenChange(false);
onEnrolled();
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
// Once backup codes have been shown, a close is equivalent to
// finishing, so the parent can refresh the status card.
if (!next && step === 'backup') onEnrolled();
onOpenChange(next);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'qr' && 'Set up two-factor authentication'}
{step === 'confirm' && 'Confirm your authenticator'}
{step === 'backup' && 'Save your backup codes'}
</DialogTitle>
<DialogDescription className="sr-only">
Enrol a time-based one-time password (TOTP) authenticator and save
single-use backup codes.
</DialogDescription>
</DialogHeader>
{step === 'qr' && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Scan the QR code with an authenticator app such as 1Password, Bitwarden, or Google Authenticator.
</p>
<div className="flex justify-center rounded-md border border-card-border bg-card p-4 shadow-card-bevel">
{otpauthUri
? <QRCodeSVG value={otpauthUri} size={176} />
: <div className="h-[176px] w-[176px] bg-muted/20 animate-pulse rounded" />
}
</div>
<button
type="button"
onClick={() => setShowSecret((v) => !v)}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{showSecret ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
Can&apos;t scan? Show secret key
</button>
{showSecret && (
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-card-border bg-card px-3 py-2 font-mono text-xs tracking-wider break-all shadow-card-bevel">
{formatSecret(secret) || '...'}
</code>
<Button type="button" size="icon" variant="outline" onClick={handleCopySecret} disabled={!secret}>
<Copy className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
)}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" onClick={() => setStep('confirm')} disabled={!otpauthUri || loading}>
Next
</Button>
</DialogFooter>
</div>
)}
{step === 'confirm' && (
<form onSubmit={handleConfirm} className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Enter the 6-digit code shown in your authenticator app to confirm enrolment.
</p>
<div className="grid gap-2">
<Label htmlFor="mfa-confirm-code">Verification code</Label>
<Input
id="mfa-confirm-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
required
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder="123456"
/>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => setStep('qr')} disabled={loading}>Back</Button>
<Button type="submit" disabled={loading || code.length !== 6}>
{loading ? 'Verifying...' : 'Verify'}
</Button>
</DialogFooter>
</form>
)}
{step === 'backup' && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Each code can be used once if your authenticator is unavailable. Store them somewhere safe; they will not be shown again.
</p>
<div className="grid grid-cols-2 gap-2 rounded-md border border-card-border bg-card p-4 font-mono text-sm tabular-nums tracking-wider shadow-card-bevel">
{backupCodes.map((c) => (
<div key={c} className="text-center">{c}</div>
))}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={handleCopyBackupCodes}>
<Copy className="w-4 h-4 mr-2" strokeWidth={1.5} />
Copy all
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={handleDownloadBackupCodes}>
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
Download
</Button>
</div>
<DialogFooter>
<Button type="button" onClick={handleFinish}>I&apos;ve saved these</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -1,7 +1,16 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RefreshCw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { RefreshCw, Shield, ShieldCheck } from 'lucide-react';
import { MfaEnrollDialog } from '@/components/mfa/MfaEnrollDialog';
import { MfaDisableDialog } from '@/components/mfa/MfaDisableDialog';
import { MfaBackupCodesDialog } from '@/components/mfa/MfaBackupCodesDialog';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
interface AccountSectionProps {
authData: { oldPassword: string; newPassword: string; confirmPassword: string };
@@ -10,7 +19,68 @@ interface AccountSectionProps {
isSaving: boolean;
}
interface MfaStatus {
enabled: boolean;
backupCodesRemaining: number;
sso_enforce_mfa: boolean;
}
interface SSOProvider {
provider: string;
type: 'ldap' | 'oidc';
}
export function AccountSection({ authData, onAuthDataChange, onPasswordChange, isSaving }: AccountSectionProps) {
const [mfa, setMfa] = useState<MfaStatus | null>(null);
const [mfaLoading, setMfaLoading] = useState(true);
const [hasSso, setHasSso] = useState(false);
const [enrollOpen, setEnrollOpen] = useState(false);
const [disableOpen, setDisableOpen] = useState(false);
const [regenOpen, setRegenOpen] = useState(false);
const [togglingBypass, setTogglingBypass] = useState(false);
const refreshMfa = useCallback(async () => {
setMfaLoading(true);
try {
const res = await apiFetch('/auth/mfa/status', { localOnly: true });
if (res.ok) setMfa(await res.json());
} catch {
// Non-fatal; surface as disabled card.
} finally {
setMfaLoading(false);
}
}, []);
useEffect(() => {
refreshMfa();
apiFetch('/auth/sso/providers', { localOnly: true })
.then((r) => (r.ok ? r.json() : []))
.then((providers: SSOProvider[]) => setHasSso(providers.length > 0))
.catch(() => setHasSso(false));
}, [refreshMfa]);
const handleBypassToggle = async (enforce: boolean) => {
setTogglingBypass(true);
try {
const res = await apiFetch('/auth/mfa/sso-bypass', {
method: 'PUT',
localOnly: true,
body: JSON.stringify({ enforce }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
toast.error(data?.error || data?.message || 'Could not update SSO preference');
return;
}
setMfa((prev) => (prev ? { ...prev, sso_enforce_mfa: enforce } : prev));
} catch (err) {
const e = err as { message?: string; error?: string } | undefined;
toast.error(e?.message || e?.error || 'Could not update SSO preference');
} finally {
setTogglingBypass(false);
}
};
return (
<div className="space-y-6">
<div>
@@ -49,6 +119,79 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
}
</Button>
</div>
<Separator />
{/* Two-factor authentication card */}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-5 max-w-lg">
<div className="flex items-start gap-3">
{mfa?.enabled
? <ShieldCheck className="w-5 h-5 mt-0.5 text-success" strokeWidth={1.5} />
: <Shield className="w-5 h-5 mt-0.5 text-muted-foreground" strokeWidth={1.5} />
}
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Two-factor authentication</h4>
{mfa?.enabled && <Badge variant="secondary">Enabled</Badge>}
</div>
<p className="text-sm text-muted-foreground mt-1">
{mfa?.enabled
? 'Sign-in requires a code from your authenticator app. Back up your codes somewhere safe.'
: 'Add a time-based one-time password to your account for an extra layer of security.'}
</p>
{mfaLoading ? (
<div className="mt-4 text-xs text-muted-foreground">Loading</div>
) : mfa?.enabled ? (
<div className="mt-4 space-y-3">
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining
</div>
{hasSso && (
<div className="flex items-start justify-between gap-3 rounded-md border border-card-border bg-background/40 p-3">
<div>
<div className="text-sm">Require 2FA even when signing in via SSO</div>
<div className="text-xs text-muted-foreground mt-0.5">
SSO logins skip the second factor by default.
</div>
</div>
<Switch
checked={mfa.sso_enforce_mfa}
onCheckedChange={handleBypassToggle}
disabled={togglingBypass}
/>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" onClick={() => setRegenOpen(true)}>
Regenerate backup codes
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDisableOpen(true)}
>
Disable 2FA
</Button>
</div>
</div>
) : (
<div className="mt-4">
<Button size="sm" onClick={() => setEnrollOpen(true)}>
Set up 2FA
</Button>
</div>
)}
</div>
</div>
</div>
<MfaEnrollDialog open={enrollOpen} onOpenChange={setEnrollOpen} onEnrolled={refreshMfa} />
<MfaDisableDialog open={disableOpen} onOpenChange={setDisableOpen} onDisabled={refreshMfa} />
<MfaBackupCodesDialog open={regenOpen} onOpenChange={setRegenOpen} onRegenerated={refreshMfa} />
</div>
);
}
@@ -15,7 +15,7 @@ import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { PaidGate } from '@/components/PaidGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
import { RefreshCw, Trash2, Plus, Pencil, ShieldOff } from 'lucide-react';
interface UserItem {
id: number;
@@ -23,6 +23,7 @@ interface UserItem {
role: UserRole;
auth_provider: string;
created_at: number;
mfaEnabled?: boolean;
}
interface RoleAssignmentItem {
@@ -129,6 +130,22 @@ export function UsersSection() {
}
};
const handleResetMfa = async (userId: number, username: string) => {
try {
const res = await apiFetch(`/users/${userId}/mfa/reset`, { method: 'POST', localOnly: true });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to reset two-factor authentication.');
return;
}
toast.success(`Two-factor authentication reset for ${username}.`);
fetchUsers();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
const handleDelete = async (userId: number) => {
try {
const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true });
@@ -427,6 +444,27 @@ export function UsersSection() {
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{u.mfaEnabled && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" title="Reset 2FA">
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset two-factor authentication for "{u.username}"?</AlertDialogTitle>
<AlertDialogDescription>
This removes the user's authenticator enrolment and backup codes. They will sign in with just their password on their next login and can re-enrol from their account settings. Use this when a user has lost access to their authenticator.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleResetMfa(u.id, u.username)}>Reset 2FA</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" disabled={isSelf}>
+64 -5
View File
@@ -1,6 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'authenticated';
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
@@ -30,8 +30,10 @@ interface AuthContextType {
isAdmin: boolean;
permissions: PermissionsData | null;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>;
cancelMfa: () => Promise<void>;
logout: () => Promise<void>;
completeSetup: () => void;
checkAuth: () => Promise<void>;
@@ -59,6 +61,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return;
}
// If a partial-auth (mfa_pending) cookie is active, route to the
// challenge screen. This handles reloads in the middle of the flow,
// including post-OIDC redirects.
if (statusData.mfaPending) {
setUser(null);
setPermissions(null);
setAppStatus('mfaChallenge');
return;
}
// Then check if already authenticated
const authResponse = await fetch('/api/auth/check', {
credentials: 'include',
@@ -115,7 +127,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return false;
}, [permissions]);
const login = async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
const login = async (username: string, password: string): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
@@ -129,6 +141,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await response.json();
if (response.ok && data.success) {
if (data.mfaRequired) {
// Password was accepted but a second factor is required. Pull the
// updated /auth/status so the app routes to the challenge screen.
await checkAuth();
return { success: true, mfaRequired: true };
}
setAppStatus('authenticated');
// Fetch user info (role, username) so isAdmin is correct immediately
await checkAuth();
@@ -141,7 +159,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const ssoLdapLogin = async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
const ssoLdapLogin = async (username: string, password: string): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
const response = await fetch('/api/auth/sso/ldap', {
method: 'POST',
@@ -153,6 +171,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await response.json();
if (response.ok && data.success) {
if (data.mfaRequired) {
await checkAuth();
return { success: true, mfaRequired: true };
}
setAppStatus('authenticated');
await checkAuth();
return { success: true };
@@ -164,6 +186,41 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const submitMfa = async (
code: string,
opts: { isBackupCode?: boolean } = {},
): Promise<{ success: boolean; error?: string; retryAfter?: number }> => {
try {
const response = await fetch('/api/auth/login/mfa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, isBackupCode: opts.isBackupCode === true }),
});
const data = await response.json().catch(() => ({}));
if (response.ok && data.success) {
await checkAuth();
return { success: true };
}
const retryAfter = typeof data.retryAfter === 'number' ? data.retryAfter : undefined;
return { success: false, error: data.error || 'Verification failed', retryAfter };
} catch {
return { success: false, error: 'Network error. Please try again.' };
}
};
const cancelMfa = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch (error) {
console.error('Cancel MFA error:', error);
} finally {
setUser(null);
setPermissions(null);
setAppStatus('notAuthenticated');
}
};
const logout = async () => {
try {
await fetch('/api/auth/logout', {
@@ -195,6 +252,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
can,
login,
ssoLdapLogin,
submitMfa,
cancelMfa,
logout,
completeSetup,
checkAuth