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 })} + +
+
+
+
+ ); +}