From d97ad947ada75fb91be8eae9a340757f00f1fb1e Mon Sep 17 00:00:00 2001 From: abassibrahim591 Date: Wed, 24 Jun 2026 15:15:44 +0100 Subject: [PATCH] feat(settings): Security section to manage 2FA from the app Lets a signed-in user set up, manage, and (when not mandatory) turn off two-factor without going through the at-login challenge. Needed for the flag-off-first rollout: you can now enrol from Settings before enforcing. Backend (session-authenticated, distinct from the challenge-token enroll/verify): - POST /auth/2fa/setup - provision a pending secret + otpauth URI - POST /auth/2fa/activate - confirm a code, enable, return recovery codes - POST /auth/2fa/disable - turn off (blocked while PB_REQUIRE_2FA; needs a code) - POST /auth/2fa/recovery-codes - regenerate (needs a code) Disable/regenerate require a live TOTP (recovery codes not accepted) so a hijacked session can't weaken the account; all share the per-account lockout. Frontend: - lib/auth/api.ts: get2faStatus/setup2fa/activate2fa/disable2fa/regenerate. - New Settings "Security" tab (SecuritySection): status, QR-less enrollment (otpauth deep link + manual key), one-time recovery codes, regenerate, and turn-off (hidden when 2FA is required). Gates: ruff, mypy (132 files), red-team eval (0), 12 2FA tests (5 new) + auth tests; web typecheck/lint/build + 132 web tests all green. Co-Authored-By: Claude Opus 4.8 --- app/api/routes_auth.py | 144 +++++++++ .../apps/web/app/settings/SecuritySection.tsx | 284 ++++++++++++++++++ .../apps/web/app/settings/SettingsClient.tsx | 27 +- frontend/apps/web/lib/auth/api.ts | 61 ++++ tests/test_2fa.py | 59 ++++ 5 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 frontend/apps/web/app/settings/SecuritySection.tsx 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}
  • + ))} +
+ +
+ ); +} + +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 ? ( +
+ + +
+ ) : 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} + + +
+
+ setCode(e.target.value)} + placeholder="123456" + className={codeInput} + /> +
+ + +
+
+ ) : ( + + ) + ) : 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} + /> +
+ + +
+
+ ) : ( +
+ + {!status.required ? ( + + ) : null} +
+ ) + ) : null} + + +
+ ); +} 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)