From 2a6e5aabb6f6665f76c633c439a2507705d8fce4 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:45:15 +0800 Subject: [PATCH] feat(auth): enforce the TOTP requirement With security.totp_required on, a console session whose user has not enrolled TOTP (password or SSO sign-in) only reaches /api/auth/me, register-key, logout and the TOTP status/setup/verify-setup endpoints. Everything else answers 403 with error type totp_enrollment_required. The gate is decided per request in require_auth, so switching the setting on covers existing sessions and enrolling lifts it on the same session. API keys are unaffected. /api/auth/me reports totp_enrollment_required; the console replaces itself with an enrollment screen while it is set and reloads the user when any request is refused with that type. Disabling TOTP is refused while the setting is on. Co-Authored-By: Claude Opus 5.5 --- crates/common/src/errors.rs | 13 ++ crates/server/src/handlers/auth.rs | 51 +++-- crates/server/src/middleware/auth_guard.rs | 56 +++-- crates/test-support/tests/admin_access.rs | 41 ++++ crates/test-support/tests/totp_required.rs | 221 ++++++++++++++++++++ web/scripts/check-i18n.mjs | 1 + web/src/components/auth/totp-enrollment.tsx | 169 +++++++++++++++ web/src/hooks/use-auth.test.tsx | 30 +++ web/src/hooks/use-auth.ts | 20 +- web/src/i18n/en.json | 9 +- web/src/i18n/zh.json | 9 +- web/src/lib/api.test.ts | 38 ++++ web/src/lib/api.ts | 17 +- web/src/lib/schemas.ts | 3 + web/src/routes/profile.tsx | 150 +------------ web/src/routes/root.tsx | 14 +- web/src/routes/totp-enrollment.test.tsx | 19 ++ web/src/routes/totp-enrollment.tsx | 47 +++++ 18 files changed, 735 insertions(+), 173 deletions(-) create mode 100644 crates/test-support/tests/totp_required.rs create mode 100644 web/src/components/auth/totp-enrollment.tsx create mode 100644 web/src/routes/totp-enrollment.test.tsx create mode 100644 web/src/routes/totp-enrollment.tsx diff --git a/crates/common/src/errors.rs b/crates/common/src/errors.rs index 25a8c483..0ef7eda4 100644 --- a/crates/common/src/errors.rs +++ b/crates/common/src/errors.rs @@ -31,6 +31,14 @@ pub enum AppError { #[error("{0}")] Forbidden(String), + /// The platform requires TOTP (`security.totp_required`) and the + /// signed-in user has not enrolled. The session stays valid but + /// only reaches the enrollment endpoints until they do; the + /// console switches on the `totp_enrollment_required` type to send + /// the user to enrollment. + #[error("Two-factor authentication must be set up before continuing")] + TotpEnrollmentRequired, + #[error("{0}")] NotFound(String), @@ -74,6 +82,11 @@ impl IntoResponse for AppError { "Authentication required".to_string(), ), AppError::Forbidden(reason) => (StatusCode::FORBIDDEN, "forbidden", reason.clone()), + AppError::TotpEnrollmentRequired => ( + StatusCode::FORBIDDEN, + "totp_enrollment_required", + self.to_string(), + ), AppError::NotFound(m) => (StatusCode::NOT_FOUND, "not_found", m.clone()), AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m.clone()), AppError::RateLimited => ( diff --git a/crates/server/src/handlers/auth.rs b/crates/server/src/handlers/auth.rs index b1e725cb..799bdee7 100644 --- a/crates/server/src/handlers/auth.rs +++ b/crates/server/src/handlers/auth.rs @@ -1323,7 +1323,7 @@ pub async fn logout( pub async fn me( auth_user: AuthUser, State(state): State, -) -> Result, AppError> { +) -> Result, AppError> { let user = repo::find_active(&state.db, auth_user.claims.sub) .await? .ok_or(AppError::NotFound("User not found".into()))?; @@ -1354,21 +1354,38 @@ pub async fn me( .map(|(id, name)| think_watch_common::dto::UserTeamSummary { id, name }) .collect(); - Ok(Json(UserResponse { - id: user.id, - email: user.email, - display_name: user.display_name, - avatar_url: user.avatar_url, - is_active: user.is_active, - oidc_subject: user.oidc_subject, - role_assignments, - permissions, - denied_permissions, - teams, - created_at: user.created_at, + let totp_enrollment_required = !user.totp_enabled && state.dynamic_config.totp_required().await; + + Ok(Json(MeResponse { + user: UserResponse { + id: user.id, + email: user.email, + display_name: user.display_name, + avatar_url: user.avatar_url, + is_active: user.is_active, + oidc_subject: user.oidc_subject, + role_assignments, + permissions, + denied_permissions, + teams, + created_at: user.created_at, + }, + totp_enrollment_required, })) } +/// `GET /api/auth/me`: the profile, plus whether the session is held +/// at TOTP enrollment. +#[derive(Debug, Serialize)] +pub struct MeResponse { + #[serde(flatten)] + pub user: UserResponse, + /// The platform requires TOTP and this user has not enrolled: every + /// console endpoint other than enrollment answers 403 + /// `totp_enrollment_required` until they do. + pub totp_enrollment_required: bool, +} + /// Helper: load every role assignment (system + custom) for a single /// user. Pure read; never errors — returns an empty Vec on failure so /// the caller can keep building a response. @@ -1773,7 +1790,7 @@ pub async fn totp_verify_setup( request_body = DisableTotpRequest, responses( (status = 200, description = "TOTP disabled"), - (status = 400, description = "TOTP not enabled or SSO account"), + (status = 400, description = "TOTP not enabled, required by the platform, or SSO account"), (status = 401, description = "Unauthorized or wrong password"), ), )] @@ -1789,6 +1806,12 @@ pub async fn totp_disable( if !user.totp_enabled { return Err(AppError::BadRequest("TOTP is not enabled".into())); } + // Disabling would only put the session straight back at enrollment. + if state.dynamic_config.totp_required().await { + return Err(AppError::BadRequest( + "TOTP is required on this platform and cannot be disabled".into(), + )); + } // Verify current password. let hash = user.password_hash.as_ref().ok_or(AppError::BadRequest( diff --git a/crates/server/src/middleware/auth_guard.rs b/crates/server/src/middleware/auth_guard.rs index 4d375568..d585a521 100644 --- a/crates/server/src/middleware/auth_guard.rs +++ b/crates/server/src/middleware/auth_guard.rs @@ -2,7 +2,7 @@ use axum::{ extract::{FromRequestParts, State}, http::{Request, StatusCode, header::AUTHORIZATION, request::Parts}, middleware::Next, - response::Response, + response::{IntoResponse, Response}, }; use think_watch_auth::{api_key, jwt::Claims, rbac}; @@ -896,18 +896,22 @@ pub async fn require_auth( // would silently start authenticating again until expiry. One // indexed PK lookup per request closes the gap — cost is sub-ms // and only on the auth path. - let user_active: Option = - sqlx::query_scalar("SELECT is_active FROM users WHERE id = $1 AND deleted_at IS NULL") - .bind(claims.sub) - .fetch_optional(&state.db) - .await - .map_err(|e| { - tracing::error!(error = %e, "DB check for users.is_active failed"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - if !matches!(user_active, Some(true)) { + // + // The same lookup reads `totp_enabled` for the TOTP-requirement + // gate further down, so enforcing it costs no extra query. + let user_row: Option<(bool, bool)> = sqlx::query_as( + "SELECT is_active, totp_enabled FROM users WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(claims.sub) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "DB check for users.is_active failed"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let Some((true, totp_enabled)) = user_row else { return Err(StatusCode::UNAUTHORIZED); - } + }; let ip = extract_client_ip(&state, request.headers(), request.extensions()).await; // Mirror the empty-string filter that `extract_client_ip` applies @@ -939,6 +943,17 @@ pub async fn require_auth( }); } + // `security.totp_required`: a session whose user has not enrolled + // reaches only what enrolling needs. Decided per request (not at + // login) so switching the setting on covers sessions that already + // exist, and enrolling lifts the limit on the same session. + if !totp_enabled + && !reachable_before_totp_enrollment(request.uri().path()) + && state.dynamic_config.totp_required().await + { + return Ok(AppError::TotpEnrollmentRequired.into_response()); + } + request.extensions_mut().insert(AuthUser { claims, ip, @@ -953,6 +968,23 @@ pub async fn require_auth( Ok(next.run(request).await) } +/// Console paths a session can reach while the platform requires TOTP +/// and its user has not enrolled: who am I, the enrollment endpoints, +/// the signing-key registration every signed write depends on, and +/// logout. +const TOTP_ENROLLMENT_PATHS: &[&str] = &[ + "/api/auth/me", + "/api/auth/register-key", + "/api/auth/logout", + "/api/auth/totp/status", + "/api/auth/totp/setup", + "/api/auth/totp/verify-setup", +]; + +fn reachable_before_totp_enrollment(path: &str) -> bool { + TOTP_ENROLLMENT_PATHS.contains(&path) +} + /// Authenticate a `tw-` API key against the `console` surface and build a /// synthetic `AuthUser` from the key owner's current permissions. Inserts /// `ApiKeyAuthenticated` so `verify_signature` knows to skip HMAC. diff --git a/crates/test-support/tests/admin_access.rs b/crates/test-support/tests/admin_access.rs index 7f172d2f..5253bbaf 100644 --- a/crates/test-support/tests/admin_access.rs +++ b/crates/test-support/tests/admin_access.rs @@ -752,3 +752,44 @@ async fn requiring_totp_is_reported_to_users() { let status = get(&admin, "/api/auth/totp/status").await; assert_eq!(status["required"], true, "{status}"); } + +/// With `security.totp_required` on, an SSO sign-in of a user who has +/// not enrolled gets a session held at enrollment, exactly like a +/// password sign-in (`totp_required.rs`), and enrolling releases it. +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn an_unenrolled_sso_user_is_held_at_totp_enrollment() { + let app = TestApp::spawn_reaching_loopback().await; + let admin = admin_session(&app).await; + let idp = mock_idp().await; + activate_sso(&app, &admin, &idp).await; + app.set_setting("auth.default_role", json!("developer")) + .await; + app.set_setting("security.totp_required", json!(true)).await; + + let identity = json!({"sub": unique_name("sub"), "email": unique_email()}); + let (con, status) = sso_login(&app, &idp, identity).await; + assert_eq!(status, 307); + + let me = get(&con, "/api/auth/me").await; + assert_eq!(me["totp_enrollment_required"], true, "{me}"); + let resp = con.get("/api/keys").await.unwrap(); + resp.assert_status(403); + let body: Value = resp.json().unwrap(); + assert_eq!(body["error"]["type"], "totp_enrollment_required", "{body}"); + + let setup: Value = con + .post_empty("/api/auth/totp/setup") + .await + .unwrap() + .json() + .unwrap(); + let email = me["email"].as_str().unwrap(); + let code = + think_watch_auth::totp::current_code(setup["secret"].as_str().unwrap(), email).unwrap(); + con.post("/api/auth/totp/verify-setup", json!({"code": code})) + .await + .unwrap() + .assert_ok(); + con.get("/api/keys").await.unwrap().assert_ok(); +} diff --git a/crates/test-support/tests/totp_required.rs b/crates/test-support/tests/totp_required.rs new file mode 100644 index 00000000..f2da88ab --- /dev/null +++ b/crates/test-support/tests/totp_required.rs @@ -0,0 +1,221 @@ +//! `security.totp_required` is enforced on console sessions. +//! +//! A user who has not enrolled TOTP still signs in, but the session +//! only reaches the enrollment endpoints (`/api/auth/me`, the TOTP +//! status / setup / verify-setup, `register-key`, logout); everything +//! else answers 403 with the `totp_enrollment_required` type. The gate +//! is decided per request, so enrolling lifts it on the same session. +//! API keys are not sessions and are not held at enrollment. +//! +//! SSO sign-in is covered in `admin_access.rs`, next to its mock +//! identity provider. + +use serde_json::Value; +use think_watch_test_support::prelude::*; + +async fn login(app: &TestApp, user: &fixtures::SeededUser) -> TestClient { + let con = app.console_client(); + con.post( + "/api/auth/login", + json!({"email": user.user.email, "password": user.plaintext_password}), + ) + .await + .unwrap() + .assert_ok(); + con +} + +fn assert_held_at_enrollment(resp: &think_watch_test_support::client::TestResponse, what: &str) { + assert_eq!(resp.status.as_u16(), 403, "{what}: {}", resp.text()); + let body: Value = resp.json().unwrap(); + assert_eq!( + body["error"]["type"], "totp_enrollment_required", + "{what}: {body}" + ); +} + +/// Run the real setup → verify-setup exchange with a code computed +/// from the secret the server hands back. +async fn enroll(con: &TestClient, email: &str) { + let setup: Value = con + .post_empty("/api/auth/totp/setup") + .await + .unwrap() + .json() + .unwrap(); + let secret = setup["secret"].as_str().unwrap(); + let code = think_watch_auth::totp::current_code(secret, email).unwrap(); + con.post("/api/auth/totp/verify-setup", json!({"code": code})) + .await + .unwrap() + .assert_ok(); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn an_unenrolled_session_reaches_only_enrollment_until_it_enrolls() { + let app = TestApp::spawn().await; + app.set_setting("security.totp_required", json!(true)).await; + // A super-admin: the requirement has no exemptions. + let admin = fixtures::create_admin_user(&app.db).await.unwrap(); + let con = login(&app, &admin).await; + + // What the enrollment screen needs. + let me: Value = con.get("/api/auth/me").await.unwrap().json().unwrap(); + assert_eq!(me["email"], admin.user.email.as_str()); + assert_eq!(me["totp_enrollment_required"], true, "{me}"); + let status: Value = con + .get("/api/auth/totp/status") + .await + .unwrap() + .json() + .unwrap(); + assert_eq!(status, json!({"enabled": false, "required": true})); + + // Everything else, reads and writes, user and admin routes. + for path in [ + "/api/keys", + "/api/dashboard/stats", + "/api/health", + "/api/admin/settings", + "/api/admin/users", + ] { + assert_held_at_enrollment(&con.get(path).await.unwrap(), path); + } + assert_held_at_enrollment( + &con.post( + "/api/keys", + json!({"name": "blocked", "surfaces": ["ai_gateway"]}), + ) + .await + .unwrap(), + "POST /api/keys", + ); + assert_held_at_enrollment( + &con.post( + "/api/auth/password", + json!({"old_password": admin.plaintext_password, "new_password": "Another-Passw0rd!"}), + ) + .await + .unwrap(), + "POST /api/auth/password", + ); + + enroll(&con, &admin.user.email).await; + + // Same session, no re-login. + con.get("/api/keys").await.unwrap().assert_ok(); + con.get("/api/admin/settings").await.unwrap().assert_ok(); + let me: Value = con.get("/api/auth/me").await.unwrap().json().unwrap(); + assert_eq!(me["totp_enrollment_required"], false, "{me}"); + + // Disabling while the setting is on would only lead straight back + // to enrollment, so it is refused. + con.post( + "/api/auth/totp/disable", + json!({"old_password": admin.plaintext_password}), + ) + .await + .unwrap() + .assert_status(400); + + // Logout stays reachable (checked on a fresh unenrolled session). + let other = fixtures::create_random_user(&app.db).await.unwrap(); + let con = login(&app, &other).await; + con.post_empty("/api/auth/logout") + .await + .unwrap() + .assert_ok(); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn switching_the_setting_on_holds_sessions_that_already_exist() { + let app = TestApp::spawn().await; + let user = fixtures::create_random_user(&app.db).await.unwrap(); + let con = login(&app, &user).await; + con.get("/api/keys").await.unwrap().assert_ok(); + + app.set_setting("security.totp_required", json!(true)).await; + assert_held_at_enrollment(&con.get("/api/keys").await.unwrap(), "after switch-on"); + + app.set_setting("security.totp_required", json!(false)) + .await; + con.get("/api/keys").await.unwrap().assert_ok(); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn an_enrolled_user_is_unaffected() { + let app = TestApp::spawn().await; + let user = fixtures::create_random_user(&app.db).await.unwrap(); + let con = login(&app, &user).await; + enroll(&con, &user.user.email).await; + + app.set_setting("security.totp_required", json!(true)).await; + con.get("/api/keys").await.unwrap().assert_ok(); + let me: Value = con.get("/api/auth/me").await.unwrap().json().unwrap(); + assert_eq!(me["totp_enrollment_required"], false, "{me}"); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn with_the_setting_off_an_unenrolled_user_is_unrestricted() { + let app = TestApp::spawn().await; + let user = fixtures::create_random_user(&app.db).await.unwrap(); + let con = login(&app, &user).await; + + con.get("/api/keys").await.unwrap().assert_ok(); + let me: Value = con.get("/api/auth/me").await.unwrap().json().unwrap(); + assert_eq!(me["totp_enrollment_required"], false, "{me}"); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn api_keys_are_not_held_at_enrollment() { + let app = TestApp::spawn().await; + app.set_setting("security.totp_required", json!(true)).await; + + let upstream = MockProvider::openai_chat_ok("gpt-4o-mini-test").await; + let provider = fixtures::create_provider( + &app.db, + &unique_name("openai-totp"), + "openai", + &upstream.uri(), + None, + ) + .await + .unwrap(); + fixtures::create_model_and_route(&app.db, provider.id, "gpt-4o-mini-test") + .await + .unwrap(); + app.rebuild_gateway_router().await; + + // The key's owner has never enrolled. + let owner = fixtures::create_random_user(&app.db).await.unwrap(); + + let gateway_key = + fixtures::create_api_key(&app.db, owner.user.id, "gw", &["ai_gateway"], None, None) + .await + .unwrap(); + let gw = app.gateway_client(); + gw.set_bearer(gateway_key.plaintext); + gw.post( + "/v1/chat/completions", + json!({ + "model": "gpt-4o-mini-test", + "messages": [{"role": "user", "content": "ping"}] + }), + ) + .await + .unwrap() + .assert_ok(); + + let console_key = + fixtures::create_api_key(&app.db, owner.user.id, "console", &["console"], None, None) + .await + .unwrap(); + let con = app.console_client(); + con.set_bearer(console_key.plaintext); + con.get("/api/keys").await.unwrap().assert_ok(); +} diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs index 73b10a2d..98e4085b 100644 --- a/web/scripts/check-i18n.mjs +++ b/web/scripts/check-i18n.mjs @@ -113,6 +113,7 @@ const DYNAMIC_ENUMS = { 'errors.byType.${_}': [ 'unauthorized', 'forbidden', 'not_found', 'bad_request', 'rate_limited', 'conflict', 'service_unavailable', 'internal_error', + 'totp_enrollment_required', ], }; diff --git a/web/src/components/auth/totp-enrollment.tsx b/web/src/components/auth/totp-enrollment.tsx new file mode 100644 index 00000000..d34c28de --- /dev/null +++ b/web/src/components/auth/totp-enrollment.tsx @@ -0,0 +1,169 @@ +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { QRCodeSVG } from 'qrcode.react'; +import { AlertCircle, Check, Copy, Download } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { apiPost, describeApiError } from '@/lib/api'; + +interface TotpSetup { + secret: string; + otpauth_uri: string; + recovery_codes: string[]; +} + +/** + * The TOTP enrollment steps: start, scan the QR code and keep the + * recovery codes, then confirm with a code from the authenticator app. + * Used on the profile page and on the screen a session is held at while + * the platform requires TOTP. + */ +export function TotpEnrollment({ onEnrolled }: { onEnrolled: () => void | Promise }) { + const { t } = useTranslation(); + const [setup, setSetup] = useState(null); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [codesCopied, setCodesCopied] = useState(false); + + const start = async () => { + setError(''); + setLoading(true); + try { + setSetup(await apiPost('/api/auth/totp/setup', {})); + } catch (err) { + setError(describeApiError(err, t)); + } finally { + setLoading(false); + } + }; + + const verify = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await apiPost('/api/auth/totp/verify-setup', { code }); + setSetup(null); + setCode(''); + await onEnrolled(); + } catch (err) { + setError(describeApiError(err, t)); + } finally { + setLoading(false); + } + }; + + const cancel = () => { + setSetup(null); + setCode(''); + setError(''); + }; + + const copyRecoveryCodes = async () => { + if (!setup) return; + await navigator.clipboard.writeText(setup.recovery_codes.join('\n')); + setCodesCopied(true); + setTimeout(() => setCodesCopied(false), 2000); + }; + + const downloadRecoveryCodes = () => { + if (!setup) return; + const blob = new Blob([setup.recovery_codes.join('\n') + '\n'], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'thinkwatch-recovery-codes.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const errorAlert = error && ( + + + {error} + + ); + + if (!setup) { + return ( +
+ {errorAlert} + +
+ ); + } + + return ( +
+
+

{t('auth.totpScanQr')}

+
+ +
+ + + {t('auth.totpManualEntry')} + + + + {setup.secret} + + + +
+
+

{t('auth.totpRecoveryCodes')}

+
+ {setup.recovery_codes.map((c) => ( + {c} + ))} +
+
+ + +
+

{t('auth.totpRecoveryWarning')}

+
+
+ {errorAlert} +
+ + setCode(e.target.value.replace(/[^0-9]/g, ''))} + required + /> +
+
+ + +
+
+
+ ); +} diff --git a/web/src/hooks/use-auth.test.tsx b/web/src/hooks/use-auth.test.tsx index 3b81ebab..9edb1a7d 100644 --- a/web/src/hooks/use-auth.test.tsx +++ b/web/src/hooks/use-auth.test.tsx @@ -12,6 +12,7 @@ vi.mock('@/lib/api', () => ({ clearCachedPermissions: vi.fn(), registerKeyPair: vi.fn(), setCachedPermissions: vi.fn(), + TOTP_ENROLLMENT_REQUIRED_EVENT: 'thinkwatch:totp-enrollment-required', })) // logout() loads the key store lazily, and jsdom has no IndexedDB behind it. @@ -72,3 +73,32 @@ describe('useAuth — ending a session', () => { await waitFor(() => expect(result.current.user).toBeNull()) }) }) + +// The platform can start requiring TOTP while a console tab is open. The +// first request refused for it fires the event; the hook reloads the user, +// whose `totp_enrollment_required` switches the console to enrollment. +describe('useAuth — TOTP enrollment', () => { + it('reloads the user when a request is held at enrollment', async () => { + const { result } = renderAuth() + await waitFor(() => expect(result.current.user).toEqual(signedIn)) + + const held = { ...signedIn, totp_enrollment_required: true } + vi.mocked(api).mockResolvedValue(held) + act(() => { + window.dispatchEvent(new CustomEvent('thinkwatch:totp-enrollment-required')) + }) + + await waitFor(() => expect(result.current.user).toEqual(held)) + }) + + it('reloads the user once enrollment completes', async () => { + vi.mocked(api).mockResolvedValue({ ...signedIn, totp_enrollment_required: true }) + const { result } = renderAuth() + await waitFor(() => expect(result.current.user?.totp_enrollment_required).toBe(true)) + + vi.mocked(api).mockResolvedValue({ ...signedIn, totp_enrollment_required: false }) + await act(() => result.current.handleTotpEnrolled()) + + await waitFor(() => expect(result.current.user?.totp_enrollment_required).toBe(false)) + }) +}) diff --git a/web/src/hooks/use-auth.ts b/web/src/hooks/use-auth.ts index 859dac78..82643f45 100644 --- a/web/src/hooks/use-auth.ts +++ b/web/src/hooks/use-auth.ts @@ -4,6 +4,7 @@ import { api, apiPost, broadcastLogout, + TOTP_ENROLLMENT_REQUIRED_EVENT, clearCachedPermissions, registerKeyPair, setCachedPermissions, @@ -88,6 +89,23 @@ export function useAuth() { return () => window.removeEventListener('thinkwatch:logged-out', handler); }, [queryClient]); + // A request refused with `totp_enrollment_required` means the platform + // started requiring TOTP mid-session: reload the user so the console + // switches to the enrollment screen. + useEffect(() => { + const handler = () => { + void queryClient.refetchQueries({ queryKey: ME_KEY }); + }; + window.addEventListener(TOTP_ENROLLMENT_REQUIRED_EVENT, handler); + return () => window.removeEventListener(TOTP_ENROLLMENT_REQUIRED_EVENT, handler); + }, [queryClient]); + + /// Enrolling lifted the hold on this session; reload the user to leave + /// the enrollment screen. + const handleTotpEnrolled = useCallback(async () => { + await queryClient.refetchQueries({ queryKey: ME_KEY }); + }, [queryClient]); + const login = async ( email: string, password: string, @@ -139,5 +157,5 @@ export function useAuth() { await queryClient.refetchQueries({ queryKey: ME_KEY }); }, [queryClient]); - return { user, loading, login, logout, handleSsoCallback }; + return { user, loading, login, logout, handleSsoCallback, handleTotpEnrolled }; } diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 7654fb8f..e818280a 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -9,7 +9,8 @@ "rate_limited": "Too many requests — wait a moment and try again.", "conflict": "That conflicts with an existing record.", "service_unavailable": "Service temporarily unavailable — please retry in a few seconds.", - "internal_error": "Internal server error. The team has been notified." + "internal_error": "Internal server error. The team has been notified.", + "totp_enrollment_required": "Two-factor authentication must be set up before this is available." }, "byStatus": { "401": "Your session has expired. Please sign in again.", @@ -239,7 +240,11 @@ "totpHint": "Enter the 6-digit code from your authenticator app, or a recovery code.", "totpVerify": "Verify & Enable", "totpCopyCodes": "Copy codes", - "totpDownloadCodes": "Download .txt" + "totpDownloadCodes": "Download .txt", + "totpRequiredEnabledStatus": "Two-factor authentication is enabled. This platform requires it, so it cannot be disabled.", + "totpEnrollmentTitle": "Set up two-factor authentication", + "totpEnrollmentDescription": "This platform requires two-factor authentication. The console becomes available once it is set up.", + "totpEnrollmentSignedInAs": "Signed in as {{email}}" }, "dashboard": { "title": "Dashboard", diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json index 39ccd4bd..9349808d 100644 --- a/web/src/i18n/zh.json +++ b/web/src/i18n/zh.json @@ -9,7 +9,8 @@ "rate_limited": "请求过于频繁,请稍后重试。", "conflict": "与已有记录冲突。", "service_unavailable": "服务暂时不可用,请稍后重试。", - "internal_error": "服务器内部错误,技术团队已收到通知。" + "internal_error": "服务器内部错误,技术团队已收到通知。", + "totp_enrollment_required": "需要先设置双因素认证才能使用此功能。" }, "byStatus": { "401": "登录已失效,请重新登录。", @@ -239,7 +240,11 @@ "totpHint": "输入身份验证器中的 6 位数字代码,或使用恢复代码。", "totpVerify": "验证并启用", "totpCopyCodes": "复制代码", - "totpDownloadCodes": "下载 .txt" + "totpDownloadCodes": "下载 .txt", + "totpRequiredEnabledStatus": "双因素认证已启用。本平台要求启用,因此不可关闭。", + "totpEnrollmentTitle": "设置双因素认证", + "totpEnrollmentDescription": "本平台要求启用双因素认证,设置完成后即可使用控制台。", + "totpEnrollmentSignedInAs": "当前账号:{{email}}" }, "dashboard": { "title": "仪表盘", diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index ce5b8c93..979daae5 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -192,4 +192,42 @@ describe('write notifications', () => { expect(listener).not.toHaveBeenCalled() }) + + it('sends a session held at TOTP enrollment to enrollment', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: () => Promise.resolve({ + error: { type: 'totp_enrollment_required', message: 'Two-factor authentication must be set up before continuing' }, + }), + })) + const listener = vi.fn() + window.addEventListener(apiModule.TOTP_ENROLLMENT_REQUIRED_EVENT, listener) + try { + const err = await apiModule.api('/api/keys').catch((e: unknown) => e) + expect(err).toBeInstanceOf(apiModule.ApiError) + expect((err as InstanceType).type).toBe('totp_enrollment_required') + expect(listener).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener(apiModule.TOTP_ENROLLMENT_REQUIRED_EVENT, listener) + } + }) + + it('leaves an ordinary 403 alone', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: () => Promise.resolve({ error: { type: 'forbidden', message: 'Missing permission' } }), + })) + const listener = vi.fn() + window.addEventListener(apiModule.TOTP_ENROLLMENT_REQUIRED_EVENT, listener) + try { + await expect(apiModule.api('/api/keys')).rejects.toThrow('Missing permission') + expect(listener).not.toHaveBeenCalled() + } finally { + window.removeEventListener(apiModule.TOTP_ENROLLMENT_REQUIRED_EVENT, listener) + } + }) }) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 69f8b9cc..f8cf5ff1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -337,7 +337,7 @@ export async function api(path: string, options: ApiOptions = {}): Promise : errorBody?.message ?? body?.message ?? retryRes.statusText; const errorType: string | undefined = typeof errorBody === 'object' ? errorBody?.type : undefined; - throw new ApiError(serverMessage || 'Request failed', retryRes.status, errorType); + throw apiFailure(serverMessage || 'Request failed', retryRes.status, errorType); } } // Skip eviction for probe calls like /api/auth/me on mount — @@ -364,13 +364,26 @@ export async function api(path: string, options: ApiOptions = {}): Promise : errorBody?.message ?? body?.message ?? res.statusText; const errorType: string | undefined = typeof errorBody === 'object' ? errorBody?.type : undefined; - throw new ApiError(serverMessage || 'Request failed', res.status, errorType); + throw apiFailure(serverMessage || 'Request failed', res.status, errorType); } notifyWrite(method); return validate(path, await res.json(), options.schema); } +/// Event fired when the server holds this session at TOTP enrollment +/// (the platform started requiring TOTP after this page loaded). The +/// auth hook reloads the signed-in user, which switches the console to +/// the enrollment screen. +export const TOTP_ENROLLMENT_REQUIRED_EVENT = 'thinkwatch:totp-enrollment-required'; + +function apiFailure(message: string, status: number, type: string | undefined): ApiError { + if (type === 'totp_enrollment_required' && typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(TOTP_ENROLLMENT_REQUIRED_EVENT)); + } + return new ApiError(message, status, type); +} + /** * Structured API failure carrying the HTTP status + the server's * `error.type` tag (`unauthorized`, `forbidden`, `rate_limited`, diff --git a/web/src/lib/schemas.ts b/web/src/lib/schemas.ts index f4fd2262..17ec49c8 100644 --- a/web/src/lib/schemas.ts +++ b/web/src/lib/schemas.ts @@ -32,6 +32,9 @@ export const UserResponseSchema = z.object({ is_active: z.boolean(), permissions: z.array(z.string()), denied_permissions: z.array(z.string()), + /** The platform requires TOTP and this user has not enrolled; every + * other console endpoint answers 403 until they do. */ + totp_enrollment_required: z.boolean(), }); export type UserResponse = z.infer; diff --git a/web/src/routes/profile.tsx b/web/src/routes/profile.tsx index 503c6b1f..c1e17170 100644 --- a/web/src/routes/profile.tsx +++ b/web/src/routes/profile.tsx @@ -1,17 +1,16 @@ import { useState, useEffect, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { QRCodeSVG } from 'qrcode.react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; -import { Lock, LogOut, Trash2, ShieldCheck, AlertCircle, Copy, Check, Download } from 'lucide-react'; +import { Lock, LogOut, Trash2, ShieldCheck, AlertCircle } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { api, apiPost, apiDelete } from '@/lib/api'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { useNavigate } from '@tanstack/react-router'; +import { TotpEnrollment } from '@/components/auth/totp-enrollment'; import { useAuth } from '@/hooks/use-auth'; export function ProfilePage() { @@ -38,14 +37,9 @@ export function ProfilePage() { const [totpEnabled, setTotpEnabled] = useState(false); const [totpRequired, setTotpRequired] = useState(false); const [totpLoading, setTotpLoading] = useState(true); - const [totpSetup, setTotpSetup] = useState<{ secret: string; otpauth_uri: string; recovery_codes: string[] } | null>(null); - const [totpVerifyCode, setTotpVerifyCode] = useState(''); - const [totpVerifyError, setTotpVerifyError] = useState(''); - const [totpVerifyLoading, setTotpVerifyLoading] = useState(false); const [totpDisablePassword, setTotpDisablePassword] = useState(''); const [totpDisableError, setTotpDisableError] = useState(''); const [disableDialogOpen, setDisableDialogOpen] = useState(false); - const [codesCopied, setCodesCopied] = useState(false); useEffect(() => { api<{ enabled: boolean; required: boolean }>('/api/auth/totp/status') @@ -59,52 +53,6 @@ export function ProfilePage() { .finally(() => setTotpLoading(false)); }, []); - const handleTotpSetup = async () => { - setTotpVerifyError(''); - try { - const res = await apiPost<{ secret: string; otpauth_uri: string; recovery_codes: string[] }>('/api/auth/totp/setup', {}); - setTotpSetup(res); - } catch (err) { - setTotpVerifyError(err instanceof Error ? err.message : t('common.error')); - } - }; - - const handleTotpVerifySetup = async (e: FormEvent) => { - e.preventDefault(); - setTotpVerifyLoading(true); - setTotpVerifyError(''); - try { - await apiPost('/api/auth/totp/verify-setup', { code: totpVerifyCode }); - setTotpEnabled(true); - setTotpSetup(null); - setTotpVerifyCode(''); - } catch (err) { - setTotpVerifyError(err instanceof Error ? err.message : t('common.error')); - } finally { - setTotpVerifyLoading(false); - } - }; - - const handleCopyRecoveryCodes = async () => { - if (!totpSetup) return; - await navigator.clipboard.writeText(totpSetup.recovery_codes.join('\n')); - setCodesCopied(true); - setTimeout(() => setCodesCopied(false), 2000); - }; - - const handleDownloadRecoveryCodes = () => { - if (!totpSetup) return; - const blob = new Blob([totpSetup.recovery_codes.join('\n') + '\n'], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'thinkwatch-recovery-codes.txt'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - const handleTotpDisable = async () => { setTotpDisableError(''); try { @@ -259,10 +207,14 @@ export function ProfilePage() {

{t('common.loading')}

) : totpEnabled ? (
-

{t('auth.totpEnabledStatus')}

- +

+ {totpRequired ? t('auth.totpRequiredEnabledStatus') : t('auth.totpEnabledStatus')} +

+ {!totpRequired && ( + + )} {/* Disable dialog */} {disableDialogOpen && (
@@ -290,88 +242,8 @@ export function ProfilePage() {
)}
- ) : totpSetup ? ( -
-
-

{t('auth.totpScanQr')}

-
- -
- - - {t('auth.totpManualEntry', 'Manual entry')} - - - - {totpSetup.secret} - - - -
-
-

{t('auth.totpRecoveryCodes')}

-
- {totpSetup.recovery_codes.map((code) => ( - {code} - ))} -
-
- - -
-

{t('auth.totpRecoveryWarning')}

-
-
- {totpVerifyError && ( - - - {totpVerifyError} - - )} -
- - setTotpVerifyCode(e.target.value.replace(/[^0-9]/g, ''))} - required - /> -
-
- - -
-
-
) : ( -
- {totpVerifyError && ( - - - {totpVerifyError} - - )} - -
+ setTotpEnabled(true)} /> )} diff --git a/web/src/routes/root.tsx b/web/src/routes/root.tsx index 5a60892c..e806f1df 100644 --- a/web/src/routes/root.tsx +++ b/web/src/routes/root.tsx @@ -11,13 +11,14 @@ import { SetupStatusSchema } from '@/lib/schemas'; import { readSetupStatus, rememberSetupStatus } from '@/lib/setup-status'; import { LoginPage } from '@/routes/login'; import { SetupPage } from '@/routes/setup'; +import { TotpEnrollmentPage } from '@/routes/totp-enrollment'; // Split out of `router.tsx`: that module has to export the route tree, and a // module exporting both components and plain values loses Fast Refresh. export function RootComponent() { const { t } = useTranslation(); - const { user, loading, login, logout, handleSsoCallback } = useAuth(); + const { user, loading, login, logout, handleSsoCallback, handleTotpEnrolled } = useAuth(); const [setupChecked, setSetupChecked] = useState(readSetupStatus() !== null); const [needsSetup, setNeedsSetup] = useState(readSetupStatus()?.needs_setup ?? false); const { allowRegistration: registrationOpen } = useSsoStatus(); @@ -125,6 +126,17 @@ export function RootComponent() { ); } + // The platform requires TOTP and this user has not enrolled: the server + // refuses every other console request for the session, so the console + // is replaced by enrollment until it completes. + if (user.totp_enrollment_required) { + return ( + + + + ); + } + return ( diff --git a/web/src/routes/totp-enrollment.test.tsx b/web/src/routes/totp-enrollment.test.tsx new file mode 100644 index 00000000..9abcf02e --- /dev/null +++ b/web/src/routes/totp-enrollment.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TotpEnrollmentPage } from './totp-enrollment' + +describe('TotpEnrollmentPage', () => { + it('offers enrollment and signing out, and nothing else', async () => { + const onLogout = vi.fn() + render() + + expect(screen.getByText(/person@example\.com/)).toBeInTheDocument() + const buttons = screen.getAllByRole('button').map((b) => b.textContent) + expect(buttons).toHaveLength(2) + expect(screen.getByRole('button', { name: /enable 2fa/i })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /logout/i })) + expect(onLogout).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/routes/totp-enrollment.tsx b/web/src/routes/totp-enrollment.tsx new file mode 100644 index 00000000..222acbcf --- /dev/null +++ b/web/src/routes/totp-enrollment.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next'; +import { LogOut } from 'lucide-react'; +import { ThinkWatchMark } from '@/components/brand/think-watch-mark'; +import { TotpEnrollment } from '@/components/auth/totp-enrollment'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +/** + * Shown instead of the console while the platform requires TOTP and the + * signed-in user has not enrolled. The server refuses every other console + * request for this session until enrollment completes, so nothing else + * is reachable from here but signing out. + */ +export function TotpEnrollmentPage({ + email, + onEnrolled, + onLogout, +}: { + email: string; + onEnrolled: () => void | Promise; + onLogout: () => void | Promise; +}) { + const { t } = useTranslation(); + return ( +
+ + +
+ +
+ {t('auth.totpEnrollmentTitle')} + {t('auth.totpEnrollmentDescription')} +
+ + +
+ {t('auth.totpEnrollmentSignedInAs', { email })} + +
+
+
+
+ ); +}