diff --git a/app/api/routes_auth.py b/app/api/routes_auth.py
index 6abfbac..b6420f1 100644
--- a/app/api/routes_auth.py
+++ b/app/api/routes_auth.py
@@ -136,6 +136,16 @@ class MfaStatusResponse(BaseModel):
required: bool
+class MfaCodeRequest(BaseModel):
+ """A 6-digit TOTP (or a recovery code) for a logged-in 2FA management action."""
+
+ code: str = Field(min_length=1)
+
+
+class MfaCodesResponse(BaseModel):
+ recovery_codes: list[str]
+
+
class RefreshRequest(BaseModel):
refresh_token: str = Field(min_length=1)
@@ -599,6 +609,140 @@ async def mfa_status(who: Principal = Depends(get_principal)) -> MfaStatusRespon
)
+def _load_active_session_user(who: Principal) -> dict:
+ record = get_users_repository().get(tenant_id=who.tenant_id, user_id=who.user_id)
+ if record is None or record.get("status") != "active":
+ raise HTTPException(status_code=401, detail="account not found")
+ return record
+
+
+def _check_2fa_code(record: dict, code: str) -> bool:
+ """True if ``code`` is a valid TOTP for the user. Recovery codes are NOT
+ accepted here - management actions (disable, regenerate) should be done with
+ a live authenticator code, not by burning a backup code."""
+ return totp.verify_totp(record.get("totp_secret"), code)
+
+
+@router.post("/2fa/setup", response_model=MfaEnrollResponse)
+async def setup_2fa(who: Principal = Depends(get_principal)) -> MfaEnrollResponse:
+ """Begin 2FA enrollment for the signed-in user (the Settings flow, distinct
+ from the at-login /auth/2fa/enroll which uses a challenge token)."""
+ settings = get_settings()
+ record = _load_active_session_user(who)
+ if record.get("totp_enabled"):
+ raise HTTPException(status_code=409, detail="two-factor is already on")
+ secret = totp.generate_secret()
+ get_users_repository().set_totp_pending(
+ tenant_id=record["tenant_id"], user_id=record["id"], secret=secret
+ )
+ return MfaEnrollResponse(
+ secret=secret,
+ otpauth_uri=totp.provisioning_uri(
+ secret, account_email=record["email"], issuer=_totp_issuer(settings)
+ ),
+ issuer=_totp_issuer(settings),
+ account=record["email"],
+ )
+
+
+@router.post("/2fa/activate", response_model=MfaCodesResponse)
+async def activate_2fa(
+ req: MfaCodeRequest, who: Principal = Depends(get_principal)
+) -> MfaCodesResponse:
+ """Confirm Settings enrollment with a code, enabling 2FA and returning the
+ one-time recovery codes."""
+ record = _load_active_session_user(who)
+ if record.get("totp_enabled"):
+ raise HTTPException(status_code=409, detail="two-factor is already on")
+ secret = record.get("totp_secret")
+ if not secret:
+ raise HTTPException(status_code=400, detail="start two-factor setup first")
+ lock_key = f"{record['email']}:2fa"
+ if auth_lockout.is_locked(record["tenant_id"], lock_key):
+ raise HTTPException(status_code=429, detail="too many attempts, please wait")
+ if not totp.verify_totp(secret, req.code):
+ auth_lockout.record_failure(record["tenant_id"], lock_key)
+ raise HTTPException(status_code=401, detail="invalid code")
+ auth_lockout.record_success(record["tenant_id"], lock_key)
+ recovery_codes = totp.generate_recovery_codes()
+ get_users_repository().enable_totp(
+ tenant_id=record["tenant_id"],
+ user_id=record["id"],
+ recovery_code_hashes=totp.hash_recovery_codes(recovery_codes),
+ )
+ audit_logger.write(AuditEvent(
+ event_type="auth_2fa_enabled",
+ tenant_id=record["tenant_id"],
+ user_id=record["id"],
+ role=record["role"],
+ route="/auth/2fa/activate",
+ request={"email": record["email"]},
+ response={"enabled": True},
+ metadata={"source": "settings"},
+ ))
+ return MfaCodesResponse(recovery_codes=recovery_codes)
+
+
+@router.post("/2fa/disable", response_model=MfaStatusResponse)
+async def disable_2fa(
+ req: MfaCodeRequest, who: Principal = Depends(get_principal)
+) -> MfaStatusResponse:
+ """Turn 2FA off (Settings). Blocked while 2FA is mandatory; otherwise
+ requires a current authenticator code so a hijacked session can't disable it."""
+ settings = get_settings()
+ if bool(getattr(settings, "require_2fa", False)):
+ raise HTTPException(
+ status_code=403, detail="two-factor is required and cannot be turned off"
+ )
+ record = _load_active_session_user(who)
+ if not record.get("totp_enabled"):
+ return MfaStatusResponse(enabled=False, required=False)
+ lock_key = f"{record['email']}:2fa"
+ if auth_lockout.is_locked(record["tenant_id"], lock_key):
+ raise HTTPException(status_code=429, detail="too many attempts, please wait")
+ if not _check_2fa_code(record, req.code):
+ auth_lockout.record_failure(record["tenant_id"], lock_key)
+ raise HTTPException(status_code=401, detail="invalid code")
+ auth_lockout.record_success(record["tenant_id"], lock_key)
+ get_users_repository().disable_totp(tenant_id=record["tenant_id"], user_id=record["id"])
+ audit_logger.write(AuditEvent(
+ event_type="auth_2fa_disabled",
+ tenant_id=record["tenant_id"],
+ user_id=record["id"],
+ role=record["role"],
+ route="/auth/2fa/disable",
+ request={"email": record["email"]},
+ response={"enabled": False},
+ metadata={"source": "settings"},
+ ))
+ return MfaStatusResponse(enabled=False, required=False)
+
+
+@router.post("/2fa/recovery-codes", response_model=MfaCodesResponse)
+async def regenerate_recovery_codes(
+ req: MfaCodeRequest, who: Principal = Depends(get_principal)
+) -> MfaCodesResponse:
+ """Replace the recovery codes with a fresh set (old ones stop working).
+ Requires a current authenticator code."""
+ record = _load_active_session_user(who)
+ if not record.get("totp_enabled"):
+ raise HTTPException(status_code=400, detail="two-factor is not on")
+ lock_key = f"{record['email']}:2fa"
+ if auth_lockout.is_locked(record["tenant_id"], lock_key):
+ raise HTTPException(status_code=429, detail="too many attempts, please wait")
+ if not _check_2fa_code(record, req.code):
+ auth_lockout.record_failure(record["tenant_id"], lock_key)
+ raise HTTPException(status_code=401, detail="invalid code")
+ auth_lockout.record_success(record["tenant_id"], lock_key)
+ recovery_codes = totp.generate_recovery_codes()
+ get_users_repository().replace_recovery_codes(
+ tenant_id=record["tenant_id"],
+ user_id=record["id"],
+ recovery_code_hashes=totp.hash_recovery_codes(recovery_codes),
+ )
+ return MfaCodesResponse(recovery_codes=recovery_codes)
+
+
@router.post("/refresh", response_model=AuthResponse)
async def refresh(req: RefreshRequest) -> AuthResponse:
"""Exchange a valid refresh token for a new access token + a new refresh
diff --git a/frontend/apps/web/app/settings/SecuritySection.tsx b/frontend/apps/web/app/settings/SecuritySection.tsx
new file mode 100644
index 0000000..22f9710
--- /dev/null
+++ b/frontend/apps/web/app/settings/SecuritySection.tsx
@@ -0,0 +1,284 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import {
+ AuthError,
+ activate2fa,
+ disable2fa,
+ get2faStatus,
+ regenerateRecoveryCodes,
+ setup2fa,
+ type MfaEnrollData,
+ type MfaStatus,
+} from '@/lib/auth/api';
+
+const cardCls =
+ 'rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-neutral-700 dark:bg-neutral-900/60';
+const primaryBtn =
+ 'inline-flex h-10 items-center justify-center rounded-xl bg-gradient-to-b from-primary-500 to-primary-700 px-4 text-sm font-semibold text-white shadow-brand-primary transition-all hover:from-primary-400 hover:to-primary-600 disabled:cursor-not-allowed disabled:opacity-60';
+const ghostBtn =
+ 'inline-flex h-10 items-center justify-center rounded-xl border border-neutral-200 px-4 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800';
+const codeInput =
+ 'h-11 w-full rounded-xl border border-neutral-200 bg-white px-3.5 text-center text-lg tracking-[0.3em] focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100';
+
+function errMsg(e: unknown, fallback: string): string {
+ return e instanceof AuthError ? e.message : fallback;
+}
+
+function RecoveryCodes({ codes }: { codes: string[] }) {
+ return (
+
+
+ Save these recovery codes somewhere safe. Each works once if you lose your authenticator.
+ They will not be shown again.
+
+
+ {codes.map((c) => (
+ {c}
+ ))}
+
+
{ void navigator.clipboard?.writeText(codes.join('\n')).catch(() => {}); }}
+ >
+ Copy codes
+
+
+ );
+}
+
+export function SecuritySection({ baseUrl, token }: { baseUrl: string; token: string }) {
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Enrollment sub-flow.
+ const [enroll, setEnroll] = useState(null);
+ const [code, setCode] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [newCodes, setNewCodes] = useState(null);
+
+ // Management sub-flow (turn off / regenerate need a code).
+ const [action, setAction] = useState<'disable' | 'regenerate' | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ function refreshStatus() {
+ setLoading(true);
+ get2faStatus(baseUrl, token)
+ .then((s) => setStatus(s))
+ .catch((e) => setError(errMsg(e, 'Could not load your security settings.')))
+ .finally(() => setLoading(false));
+ }
+
+ useEffect(() => {
+ let active = true;
+ get2faStatus(baseUrl, token)
+ .then((s) => { if (active) setStatus(s); })
+ .catch((e) => { if (active) setError(errMsg(e, 'Could not load your security settings.')); })
+ .finally(() => { if (active) setLoading(false); });
+ return () => { active = false; };
+ }, [baseUrl, token]);
+
+ async function startSetup() {
+ setError(null);
+ setBusy(true);
+ try {
+ setEnroll(await setup2fa(baseUrl, token));
+ } catch (e) {
+ setError(errMsg(e, 'Could not start two-factor setup.'));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function confirmSetup() {
+ if (busy) return;
+ const c = code.trim();
+ if (!c) { setError('Enter the 6-digit code from your authenticator app.'); return; }
+ setError(null);
+ setBusy(true);
+ try {
+ const { recovery_codes } = await activate2fa(baseUrl, token, c);
+ setNewCodes(recovery_codes);
+ setEnroll(null);
+ setCode('');
+ setStatus({ enabled: true, required: status?.required ?? false });
+ } catch (e) {
+ setError(errMsg(e, 'That code did not match. Try again.'));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function confirmAction() {
+ if (busy || !action) return;
+ const c = code.trim();
+ if (!c) { setError('Enter a code from your authenticator app.'); return; }
+ setError(null);
+ setBusy(true);
+ try {
+ if (action === 'disable') {
+ const s = await disable2fa(baseUrl, token, c);
+ setStatus(s);
+ } else {
+ const { recovery_codes } = await regenerateRecoveryCodes(baseUrl, token, c);
+ setNewCodes(recovery_codes);
+ }
+ setAction(null);
+ setCode('');
+ } catch (e) {
+ setError(errMsg(e, 'That code did not match. Try again.'));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function copySecret() {
+ if (!enroll) return;
+ void navigator.clipboard?.writeText(enroll.secret).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ }).catch(() => {});
+ }
+
+ if (loading) {
+ return Loading…
;
+ }
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {/* One-time recovery codes after enable / regenerate. */}
+ {newCodes ? (
+
+
+ setNewCodes(null)}>
+ Done
+
+
+ ) : null}
+
+ {/* Status line */}
+
+
+
+ Two-factor authentication
+
+
+ {status?.enabled
+ ? 'On - a code from your authenticator app is required at sign-in.'
+ : 'Add a second step at sign-in using an authenticator app.'}
+ {status?.required ? ' Required by your organization.' : ''}
+
+
+
+ {status?.enabled ? 'On' : 'Off'}
+
+
+
+ {/* Not enrolled: setup flow */}
+ {!status?.enabled && !newCodes ? (
+ enroll ? (
+
+
+ Add this account to your authenticator app, then enter the 6-digit code it shows.
+
+
+ Open in authenticator app
+
+
+
+ Or enter this key manually
+
+
+
+ {enroll.secret}
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
setCode(e.target.value)}
+ placeholder="123456"
+ className={codeInput}
+ />
+
+
+ {busy ? 'Verifying…' : 'Verify and enable'}
+
+ { setEnroll(null); setCode(''); }}>
+ Cancel
+
+
+
+ ) : (
+
+ {busy ? 'Starting…' : 'Set up two-factor'}
+
+ )
+ ) : null}
+
+ {/* Enrolled: management actions */}
+ {status?.enabled ? (
+ action ? (
+
+
+ {action === 'disable'
+ ? 'Enter a current code to turn two-factor off.'
+ : 'Enter a current code to generate new recovery codes (old ones stop working).'}
+
+
setCode(e.target.value)}
+ placeholder="123456"
+ className={codeInput}
+ />
+
+
+ {busy ? 'Working…' : action === 'disable' ? 'Turn off' : 'Regenerate'}
+
+ { setAction(null); setCode(''); }}>
+ Cancel
+
+
+
+ ) : (
+
+ { setError(null); setAction('regenerate'); }}>
+ Regenerate recovery codes
+
+ {!status.required ? (
+ { setError(null); setAction('disable'); }}>
+ Turn off two-factor
+
+ ) : null}
+
+ )
+ ) : null}
+
+
+ Refresh
+
+
+ );
+}
diff --git a/frontend/apps/web/app/settings/SettingsClient.tsx b/frontend/apps/web/app/settings/SettingsClient.tsx
index fd47633..0deb8f2 100644
--- a/frontend/apps/web/app/settings/SettingsClient.tsx
+++ b/frontend/apps/web/app/settings/SettingsClient.tsx
@@ -20,10 +20,12 @@ import {
import { useChatStore } from '@/lib/chat/store';
import { canAdminister } from '@/lib/auth/roles';
import { getOnboardingStatus, type AccountType } from '@/lib/onboarding/api';
+import { SecuritySection } from './SecuritySection';
type Section =
| 'general'
| 'profile'
+ | 'security'
| 'instructions'
| 'data'
| 'notifications'
@@ -33,13 +35,14 @@ type Section =
interface SectionDef {
key: Section;
label: string;
- icon: 'general' | 'profile' | 'instructions' | 'data' | 'bell' | 'mic' | 'account';
+ icon: 'general' | 'profile' | 'security' | 'instructions' | 'data' | 'bell' | 'mic' | 'account';
stub?: boolean;
}
const SECTIONS: SectionDef[] = [
{ key: 'general', label: 'General', icon: 'general' },
{ key: 'profile', label: 'Profile', icon: 'profile' },
+ { key: 'security', label: 'Security', icon: 'security' },
{ key: 'instructions', label: 'Custom instructions', icon: 'instructions' },
{ key: 'data', label: 'Data controls', icon: 'data' },
{ key: 'notifications', label: 'Notifications', icon: 'bell' },
@@ -143,6 +146,18 @@ function SectionIcon({ kind }: { kind: SectionDef['icon'] }) {
/>
);
+ case 'security':
+ return (
+
+
+
+
+ );
}
}
@@ -633,6 +648,16 @@ export function SettingsClient() {
>
) : null}
+ {section === 'security' ? (
+ <>
+
+
+ >
+ ) : null}
+
{section === 'instructions' ? (
<>
,
+): Promise {
+ const res = await fetch(`${baseUrl}${path}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ });
+ if (res.ok) return res.json();
+ throw new AuthError(await errorDetail(res), res.status);
+}
+
+export async function get2faStatus(baseUrl: string, token: string): Promise {
+ const res = await fetch(`${baseUrl}/auth/2fa/status`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (res.ok) return (await res.json()) as MfaStatus;
+ throw new AuthError(await errorDetail(res), res.status);
+}
+
+/** Begin enrollment from Settings (session-authenticated, no challenge token). */
+export function setup2fa(baseUrl: string, token: string): Promise {
+ return authedPost(baseUrl, '/auth/2fa/setup', token) as Promise;
+}
+
+export function activate2fa(
+ baseUrl: string,
+ token: string,
+ code: string,
+): Promise<{ recovery_codes: string[] }> {
+ return authedPost(baseUrl, '/auth/2fa/activate', token, { code }) as Promise<{
+ recovery_codes: string[];
+ }>;
+}
+
+export function disable2fa(baseUrl: string, token: string, code: string): Promise {
+ return authedPost(baseUrl, '/auth/2fa/disable', token, { code }) as Promise;
+}
+
+export function regenerateRecoveryCodes(
+ baseUrl: string,
+ token: string,
+ code: string,
+): Promise<{ recovery_codes: string[] }> {
+ return authedPost(baseUrl, '/auth/2fa/recovery-codes', token, { code }) as Promise<{
+ recovery_codes: string[];
+ }>;
+}
+
/**
* Exchange a refresh token for a fresh access + refresh pair. Throws AuthError
* on a 401 (token used/expired/revoked) so the caller can drop the session and
diff --git a/tests/test_2fa.py b/tests/test_2fa.py
index 6a24bb1..e194c42 100644
--- a/tests/test_2fa.py
+++ b/tests/test_2fa.py
@@ -203,3 +203,62 @@ def test_signin_without_2fa_is_unchanged_when_flag_off():
body = r.json()
assert body["token"]
assert "mfa_required" not in body
+
+
+def _session(email):
+ # Flag off so signup returns a real session token we can use as a bearer.
+ _STATE["require_2fa"] = False
+ token = _signup(email).json()["token"]
+ return {"Authorization": f"Bearer {token}"}
+
+
+def test_settings_setup_then_activate_enables_2fa():
+ h = _session("setup@example.com")
+ setup = client.post("/auth/2fa/setup", headers=h)
+ assert setup.status_code == 200, setup.text
+ secret = setup.json()["secret"]
+ assert setup.json()["otpauth_uri"].startswith("otpauth://totp/")
+ act = client.post("/auth/2fa/activate", headers=h, json={"code": _code_for(secret)})
+ assert act.status_code == 200, act.text
+ assert len(act.json()["recovery_codes"]) == 10
+ assert client.get("/auth/2fa/status", headers=h).json()["enabled"] is True
+
+
+def test_settings_activate_rejects_wrong_code():
+ h = _session("badcode@example.com")
+ client.post("/auth/2fa/setup", headers=h)
+ bad = client.post("/auth/2fa/activate", headers=h, json={"code": "000000"})
+ assert bad.status_code == 401
+ assert client.get("/auth/2fa/status", headers=h).json()["enabled"] is False
+
+
+def test_settings_disable_requires_code_when_not_mandatory():
+ h = _session("disable@example.com")
+ secret = client.post("/auth/2fa/setup", headers=h).json()["secret"]
+ client.post("/auth/2fa/activate", headers=h, json={"code": _code_for(secret)})
+ bad = client.post("/auth/2fa/disable", headers=h, json={"code": "000000"})
+ assert bad.status_code == 401
+ ok = client.post("/auth/2fa/disable", headers=h, json={"code": _code_for(secret)})
+ assert ok.status_code == 200
+ assert client.get("/auth/2fa/status", headers=h).json()["enabled"] is False
+
+
+def test_settings_disable_blocked_when_2fa_required():
+ h = _session("mandatory@example.com")
+ secret = client.post("/auth/2fa/setup", headers=h).json()["secret"]
+ client.post("/auth/2fa/activate", headers=h, json={"code": _code_for(secret)})
+ _STATE["require_2fa"] = True
+ r = client.post("/auth/2fa/disable", headers=h, json={"code": _code_for(secret)})
+ assert r.status_code == 403
+
+
+def test_settings_regenerate_recovery_codes():
+ h = _session("regen@example.com")
+ secret = client.post("/auth/2fa/setup", headers=h).json()["secret"]
+ first = client.post(
+ "/auth/2fa/activate", headers=h, json={"code": _code_for(secret)}
+ ).json()["recovery_codes"]
+ regen = client.post("/auth/2fa/recovery-codes", headers=h, json={"code": _code_for(secret)})
+ assert regen.status_code == 200
+ second = regen.json()["recovery_codes"]
+ assert len(second) == 10 and set(second) != set(first)