mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 01:37:05 +00:00
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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user