From b18d8cff2d941ba849f3d7fc21019bf1217a1559 Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Thu, 15 Oct 2026 09:52:51 +0200 Subject: [PATCH 1/7] A verified email in an allowed domain is the one an SSO login may provision Signed-off-by: Bogdan Szabo --- crates/utopia-server/src/api/oidc_routes.rs | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/utopia-server/src/api/oidc_routes.rs b/crates/utopia-server/src/api/oidc_routes.rs index 8fb4c0a93..407594d2b 100644 --- a/crates/utopia-server/src/api/oidc_routes.rs +++ b/crates/utopia-server/src/api/oidc_routes.rs @@ -382,6 +382,20 @@ struct Claims { #[serde(default)] azp: Option, aud: Value, + #[serde(default)] + email: Option, + #[serde(default)] + email_verified: bool, + #[serde(default)] + name: Option, +} + +/// 能自动开户或按 email 关联的那个 email:身份提供方核实过,而且域名在允许名单里。 +/// 名单空着就永远是 `None`——默认仍然只认本人绑定过的 subject +fn provisioned_email(claims: &Claims, allowed_domains: &[String]) -> Option { + let email = claims.email.as_deref()?.trim().to_lowercase(); + let (_, domain) = email.rsplit_once('@')?; + (claims.email_verified && allowed_domains.iter().any(|d| d == domain)).then_some(email) } /// 按 `kid` 找钥匙;令牌不带 `kid` 时,只有 JWKS 里恰好一把 RSA 钥匙才用它 @@ -900,6 +914,26 @@ mod tests { assert!(super::force_allowed("https://id.example.test/another").await); } + #[test] + fn only_a_verified_email_in_an_allowed_domain_gets_an_account() { + let claims = |email: &str, verified: bool| { + serde_json::from_value::(serde_json::json!({ + "sub": "person-1", "iat": 0, "aud": "utopia-test", + "email": email, "email_verified": verified, + })) + .unwrap() + }; + let allowed = vec!["re-cinq.com".to_string()]; + assert_eq!( + super::provisioned_email(&claims("Ann@Re-Cinq.com", true), &allowed), + Some("ann@re-cinq.com".to_string()) + ); + assert_eq!(super::provisioned_email(&claims("ann@re-cinq.com", false), &allowed), None); + assert_eq!(super::provisioned_email(&claims("ann@gmail.com", true), &allowed), None); + assert_eq!(super::provisioned_email(&claims("ann@evil-re-cinq.com", true), &allowed), None); + assert_eq!(super::provisioned_email(&claims("ann@re-cinq.com", true), &[]), None); + } + fn config() -> super::Config { super::Config { issuer: "https://id.example.test".into(), From af62dc9b05497bf7670a3f95b557146bacd883a0 Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Thu, 15 Oct 2026 09:54:03 +0200 Subject: [PATCH 2/7] An SSO login finds its account by verified email, or opens a viewer account with no password Signed-off-by: Bogdan Szabo --- crates/utopia-store/src/accounts.rs | 17 ++++ ...an_sso_login_finds_or_opens_its_account.rs | 93 +++++++++++++++++++ crates/utopia-store/tests/store/main.rs | 1 + 3 files changed, 111 insertions(+) create mode 100644 crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs diff --git a/crates/utopia-store/src/accounts.rs b/crates/utopia-store/src/accounts.rs index 0e8a5f658..eaee164d7 100644 --- a/crates/utopia-store/src/accounts.rs +++ b/crates/utopia-store/src/accounts.rs @@ -354,3 +354,20 @@ pub async fn set_system_admin( } Ok(()) } + +/// 单点登录允许自动开户时,用身份提供方核实过的 email 找账号;找不到就按注册的 +/// 规则开一个(默认工作区的 viewer;部署里还没人时就是首个管理员)。 +/// +/// 开出来的账号**没有可用的密码**:`!` 不是合法的哈希,密码登录永远验不过。 +pub async fn account_for_sso(pool: &PgPool, email: &str, display_name: &str) -> AppResult { + let existing: Option = sqlx::query_as( + "SELECT * FROM users WHERE lower(email) = lower($1) AND deactivated_at IS NULL", + ) + .bind(email) + .fetch_optional(pool) + .await?; + match existing { + Some(user) => Ok(user), + None => Ok(register(pool, email, "!", display_name, None, true).await?.user), + } +} diff --git a/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs b/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs new file mode 100644 index 000000000..070c84da6 --- /dev/null +++ b/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs @@ -0,0 +1,93 @@ +//! 单点登录按核实过的 email 找账号,找不到就开一个——打在真库上。 + +use sqlx::PgPool; +use uuid::Uuid; + +/// 一个比库里其它组织都早的组织,开户会落进它的默认工作区 +async fn earliest_org(pool: &PgPool) -> anyhow::Result<(Uuid, Uuid)> { + let (org, ws) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name, created_at) VALUES ($1, 'sso-test', '2000-01-01')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name, created_at) VALUES ($1, $2, 'sso-ws', '2000-01-01')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + Ok((org, ws)) +} + +#[tokio::test] +async fn an_existing_account_is_found_whatever_the_case_of_its_email() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let (org, user) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'sso-test')") + .bind(org) + .execute(&pool) + .await?; + let email = format!("{}-ann@sso.test", user.simple()); + + let run = async { + sqlx::query( + "INSERT INTO users (id, org_id, email, display_name, password_hash) + VALUES ($1, $2, $3, 'Ann', 'x')", + ) + .bind(user) + .bind(org) + .bind(email.to_uppercase()) + .execute(&pool) + .await?; + let found = utopia_store::accounts::account_for_sso(&pool, &email, "Ann").await?; + assert_eq!(found.id, user); + Ok::<_, anyhow::Error>(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + run +} + +#[tokio::test] +async fn an_unknown_email_opens_a_viewer_account_without_a_password() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let (org, ws) = earliest_org(&pool).await?; + let email = format!("{}-bea@sso.test", Uuid::now_v7().simple()); + + let run = async { + let opened = utopia_store::accounts::account_for_sso(&pool, &email, "Bea").await?; + let role: String = sqlx::query_scalar( + "SELECT role FROM memberships WHERE user_id = $1 AND workspace_id = $2", + ) + .bind(opened.id) + .bind(ws) + .fetch_one(&pool) + .await?; + let hash: String = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") + .bind(opened.id) + .fetch_one(&pool) + .await?; + assert_eq!( + (opened.email.as_str(), opened.display_name.as_str(), opened.is_admin, opened.org_id, role.as_str()), + (email.as_str(), "Bea", false, org, "viewer") + ); + assert!(!hash.starts_with("$argon2"), "an SSO account must not have a usable password"); + Ok::<_, anyhow::Error>(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + run +} diff --git a/crates/utopia-store/tests/store/main.rs b/crates/utopia-store/tests/store/main.rs index 551594d2f..7b2516d5a 100644 --- a/crates/utopia-store/tests/store/main.rs +++ b/crates/utopia-store/tests/store/main.rs @@ -112,6 +112,7 @@ mod an_event_holds_at_the_moment_it_names; mod an_exploration_says_what_it_covered; mod an_export_carries_the_whole_ledger; mod an_open_statement_keeps_the_documents_words; +mod an_sso_login_finds_or_opens_its_account; mod an_undeclared_name_is_looked_up; mod an_unknown_date_is_not_an_open_one; mod an_untyped_name_meets_its_namesake; From 5e13d930e75c611c959be78d09eb1bdff348a001 Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Thu, 15 Oct 2026 09:55:50 +0200 Subject: [PATCH 3/7] SSO logins in an allowed domain link or open their account (UTOPIA_OIDC_ALLOWED_DOMAINS) Signed-off-by: Bogdan Szabo --- .../src/api/oidc_exchange_tests.rs | 38 +++++++++ crates/utopia-server/src/api/oidc_routes.rs | 80 ++++++++++++++----- crates/utopia-store/src/accounts.rs | 4 +- ...an_sso_login_finds_or_opens_its_account.rs | 23 ++++-- 4 files changed, 117 insertions(+), 28 deletions(-) diff --git a/crates/utopia-server/src/api/oidc_exchange_tests.rs b/crates/utopia-server/src/api/oidc_exchange_tests.rs index fdcad2628..5c3164678 100644 --- a/crates/utopia-server/src/api/oidc_exchange_tests.rs +++ b/crates/utopia-server/src/api/oidc_exchange_tests.rs @@ -47,6 +47,7 @@ async fn exchange_maps_only_linked_active_people_and_spends_each_token_once() -> let (org, user) = (Uuid::now_v7(), Uuid::now_v7()); let subject = format!("person-{user}"); + let bea_email = format!("{}@example.test", Uuid::now_v7().simple()); let dir = tempfile::tempdir()?; let cfg = utopia_core::config::AppConfig { data_dir: dir.path().to_string_lossy().into_owned(), @@ -140,10 +141,47 @@ async fn exchange_maps_only_linked_active_people_and_spends_each_token_once() -> .await?; let (status, body) = exchange(sign(&subject)?).await?; anyhow::ensure!(body["code"] == "oidc_unlinked", "deactivated: {status} {body}"); + + // 允许名单里的域名:核实过的 email 关联已有账号,没有就开一个,之后按 subject 认人 + std::env::set_var("UTOPIA_OIDC_ALLOWED_DOMAINS", "example.test"); + let sign_email = |sub: &str, email: &str, verified: bool| { + let now = chrono::Utc::now().timestamp(); + encode( + &header, + &json!({"iss": issuer, "aud": "sibling-app", "sub": sub, "iat": now, "exp": now + 300, "jti": Uuid::now_v7(), "email": email, "email_verified": verified, "name": "Bea"}), + &key, + ) + }; + let ann = Uuid::now_v7(); + sqlx::query("INSERT INTO users(id,org_id,email,password_hash,display_name) VALUES($1,$2,$3,'unused','Ann')") + .bind(ann) + .bind(org) + .bind(format!("{ann}@example.test")) + .execute(&pool) + .await?; + let user_of = |body: &Value| crate::auth::decode_user_id(&state, body["token"].as_str().unwrap_or_default()); + + let (status, body) = exchange(sign_email(&format!("sub-{ann}"), &format!("{ann}@EXAMPLE.test"), true)?).await?; + anyhow::ensure!(status == StatusCode::OK && user_of(&body)? == ann, "existing by email: {status} {body}"); + + let bea_sub = format!("sub-{}", Uuid::now_v7()); + let (status, body) = exchange(sign_email(&bea_sub, &bea_email, true)?).await?; + anyhow::ensure!(status == StatusCode::OK, "opened: {status} {body}"); + let bea = user_of(&body)?; + let (_, body) = exchange(sign_email(&bea_sub, "changed@elsewhere.test", false)?).await?; + anyhow::ensure!(user_of(&body)? == bea, "linked by subject afterwards: {body}"); + + let (status, body) = exchange(sign_email("sub-unverified", &format!("{}@example.test", Uuid::now_v7()), false)?).await?; + anyhow::ensure!(body["code"] == "oidc_unlinked", "unverified: {status} {body}"); anyhow::Ok(()) } .await; + std::env::remove_var("UTOPIA_OIDC_ALLOWED_DOMAINS"); server.abort(); + sqlx::query("DELETE FROM users WHERE email = $1") + .bind(&bea_email) + .execute(&pool) + .await?; sqlx::query("DELETE FROM organizations WHERE id=$1") .bind(org) .execute(&pool) diff --git a/crates/utopia-server/src/api/oidc_routes.rs b/crates/utopia-server/src/api/oidc_routes.rs index 407594d2b..cd570b923 100644 --- a/crates/utopia-server/src/api/oidc_routes.rs +++ b/crates/utopia-server/src/api/oidc_routes.rs @@ -61,6 +61,9 @@ struct Config { /// `exchange` 收哪些 client 的 ID token。空 = 不开放换会话。与 Utopia 共用一个 /// client 的部署把 Utopia 自己的 client id 写进来 exchange_audiences: Vec, + /// 这些域名里核实过的 email 首次登录时按 email 关联已有账号,没有就开一个 + /// (`UTOPIA_OIDC_ALLOWED_DOMAINS`)。空 = 只认本人绑定过的 subject + allowed_domains: Vec, /// 本地开发:允许 issuer 与各端点走回环地址上的明文 HTTP。只认 localhost / /// 127.0.0.1 / ::1,公网地址照旧只能 HTTPS loopback_http: bool, @@ -87,6 +90,12 @@ fn config() -> Result { .filter(|a| !a.is_empty()) .map(String::from) .collect(), + allowed_domains: std::env::var("UTOPIA_OIDC_ALLOWED_DOMAINS") + .unwrap_or_default() + .split(',') + .map(|d| d.trim().to_lowercase()) + .filter(|d| !d.is_empty()) + .collect(), loopback_http: std::env::var("UTOPIA_OIDC_ALLOW_LOOPBACK_HTTP") .is_ok_and(|v| matches!(v.trim(), "1" | "true")), }; @@ -538,14 +547,9 @@ pub async fn exchange( ); } - let user_id: Option = sqlx::query_scalar( - "SELECT i.user_id FROM oidc_identities i JOIN users u ON u.id = i.user_id - WHERE i.issuer = $1 AND i.subject = $2 AND u.deactivated_at IS NULL", - ) - .bind(&c.issuer) - .bind(&claims.sub) - .fetch_optional(&s.pool) - .await?; + let user_id = account_of(&s, &c, &claims) + .await + .map_err(|r| AppError::invalid("oidc_unavailable", r.reason))?; let user_id = user_id.ok_or_else(|| { AppError::invalid( "oidc_unlinked", @@ -703,15 +707,7 @@ async fn finish( Ok(Done::Linked) } None => { - let user_id: Option = sqlx::query_scalar( - "SELECT i.user_id FROM oidc_identities i JOIN users u ON u.id = i.user_id - WHERE i.issuer = $1 AND i.subject = $2 AND u.deactivated_at IS NULL", - ) - .bind(&c.issuer) - .bind(&claims.sub) - .fetch_optional(&s.pool) - .await?; - let user_id = user_id.ok_or_else(|| { + let user_id = account_of(s, &c, &claims).await?.ok_or_else(|| { refuse("unlinked", format!("subject {} is not linked", claims.sub)) })?; let _ = utopia_store::audit::record( @@ -729,6 +725,35 @@ async fn finish( } } +/// 这个 subject 登进哪个账号:本人绑定过的优先;没绑定、而 email 落在允许的域名里, +/// 就按 email 找账号(找不到开一个)并替他绑上 +async fn account_of(s: &AppState, c: &Config, claims: &Claims) -> Result, Refusal> { + let linked: Option = sqlx::query_scalar( + "SELECT i.user_id FROM oidc_identities i JOIN users u ON u.id = i.user_id + WHERE i.issuer = $1 AND i.subject = $2 AND u.deactivated_at IS NULL", + ) + .bind(&c.issuer) + .bind(&claims.sub) + .fetch_optional(&s.pool) + .await?; + let Some(email) = linked + .is_none() + .then(|| provisioned_email(claims, &c.allowed_domains)) + .flatten() + else { + return Ok(linked); + }; + let name = claims + .name + .clone() + .unwrap_or_else(|| email.split('@').next().unwrap_or_default().to_string()); + let user = utopia_store::accounts::account_for_sso(&s.pool, &email, &name) + .await + .map_err(|e| refuse("unavailable", format!("provision: {e}")))?; + link(s, c, user.id, &claims.sub).await?; + Ok(Some(user.id)) +} + async fn link(s: &AppState, c: &Config, user_id: Uuid, subject: &str) -> Result<(), Refusal> { let holder: Option = sqlx::query_scalar( "SELECT user_id FROM oidc_identities WHERE issuer = $1 AND subject = $2", @@ -928,10 +953,22 @@ mod tests { super::provisioned_email(&claims("Ann@Re-Cinq.com", true), &allowed), Some("ann@re-cinq.com".to_string()) ); - assert_eq!(super::provisioned_email(&claims("ann@re-cinq.com", false), &allowed), None); - assert_eq!(super::provisioned_email(&claims("ann@gmail.com", true), &allowed), None); - assert_eq!(super::provisioned_email(&claims("ann@evil-re-cinq.com", true), &allowed), None); - assert_eq!(super::provisioned_email(&claims("ann@re-cinq.com", true), &[]), None); + assert_eq!( + super::provisioned_email(&claims("ann@re-cinq.com", false), &allowed), + None + ); + assert_eq!( + super::provisioned_email(&claims("ann@gmail.com", true), &allowed), + None + ); + assert_eq!( + super::provisioned_email(&claims("ann@evil-re-cinq.com", true), &allowed), + None + ); + assert_eq!( + super::provisioned_email(&claims("ann@re-cinq.com", true), &[]), + None + ); } fn config() -> super::Config { @@ -941,6 +978,7 @@ mod tests { secret: None, redirect: "https://utopia.example.test/api/v1/auth/oidc/callback".into(), exchange_audiences: vec!["sibling-app".into(), "utopia-test".into()], + allowed_domains: vec![], loopback_http: false, } } diff --git a/crates/utopia-store/src/accounts.rs b/crates/utopia-store/src/accounts.rs index eaee164d7..eb977ff56 100644 --- a/crates/utopia-store/src/accounts.rs +++ b/crates/utopia-store/src/accounts.rs @@ -368,6 +368,8 @@ pub async fn account_for_sso(pool: &PgPool, email: &str, display_name: &str) -> .await?; match existing { Some(user) => Ok(user), - None => Ok(register(pool, email, "!", display_name, None, true).await?.user), + None => Ok(register(pool, email, "!", display_name, None, true) + .await? + .user), } } diff --git a/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs b/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs index 070c84da6..8464df02c 100644 --- a/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs +++ b/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs @@ -6,10 +6,12 @@ use uuid::Uuid; /// 一个比库里其它组织都早的组织,开户会落进它的默认工作区 async fn earliest_org(pool: &PgPool) -> anyhow::Result<(Uuid, Uuid)> { let (org, ws) = (Uuid::now_v7(), Uuid::now_v7()); - sqlx::query("INSERT INTO organizations (id, name, created_at) VALUES ($1, 'sso-test', '2000-01-01')") - .bind(org) - .execute(pool) - .await?; + sqlx::query( + "INSERT INTO organizations (id, name, created_at) VALUES ($1, 'sso-test', '2000-01-01')", + ) + .bind(org) + .execute(pool) + .await?; sqlx::query("INSERT INTO workspaces (id, org_id, name, created_at) VALUES ($1, $2, 'sso-ws', '2000-01-01')") .bind(ws) .bind(org) @@ -77,10 +79,19 @@ async fn an_unknown_email_opens_a_viewer_account_without_a_password() -> anyhow: .fetch_one(&pool) .await?; assert_eq!( - (opened.email.as_str(), opened.display_name.as_str(), opened.is_admin, opened.org_id, role.as_str()), + ( + opened.email.as_str(), + opened.display_name.as_str(), + opened.is_admin, + opened.org_id, + role.as_str() + ), (email.as_str(), "Bea", false, org, "viewer") ); - assert!(!hash.starts_with("$argon2"), "an SSO account must not have a usable password"); + assert!( + !hash.starts_with("$argon2"), + "an SSO account must not have a usable password" + ); Ok::<_, anyhow::Error>(()) } .await; From 29d027c740c52b554c47f440d4642bb9636c4828 Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Thu, 15 Oct 2026 09:57:20 +0200 Subject: [PATCH 4/7] UTOPIA_PASSWORD_LOGIN=false refuses password sign-in and registration Signed-off-by: Bogdan Szabo --- crates/utopia-core/src/config.rs | 5 ++ crates/utopia-server/src/api/auth_routes.rs | 13 ++++ crates/utopia-server/src/api/mod.rs | 3 + crates/utopia-server/src/api/oidc_routes.rs | 4 +- .../src/api/password_login_tests.rs | 72 +++++++++++++++++++ crates/utopia-server/src/state.rs | 2 + 6 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 crates/utopia-server/src/api/password_login_tests.rs diff --git a/crates/utopia-core/src/config.rs b/crates/utopia-core/src/config.rs index c00a2cf40..e4910a860 100644 --- a/crates/utopia-core/src/config.rs +++ b/crates/utopia-core/src/config.rs @@ -42,6 +42,10 @@ pub struct AppConfig { pub cookie_secure: bool, /// 是否开放注册。false 时仅首个用户(引导部署)可注册,其余需管理员开放。 pub open_registration: bool, + /// 能不能用密码登录、注册。关掉之后只走单点登录(配合 + /// `UTOPIA_OIDC_ALLOWED_DOMAINS` 自动开户);打开就是身份提供方出事时的后门。 + /// `UTOPIA_PASSWORD_LOGIN` + pub password_login: bool, } impl Default for AppConfig { @@ -59,6 +63,7 @@ impl Default for AppConfig { db_max_connections: None, cookie_secure: false, open_registration: true, + password_login: true, } } } diff --git a/crates/utopia-server/src/api/auth_routes.rs b/crates/utopia-server/src/api/auth_routes.rs index f17f39cb4..20ad09974 100644 --- a/crates/utopia-server/src/api/auth_routes.rs +++ b/crates/utopia-server/src/api/auth_routes.rs @@ -50,6 +50,7 @@ pub async fn register( jar: CookieJar, Json(req): Json, ) -> ApiResult<(CookieJar, Json)> { + password_login_enabled(&state)?; validate_register(&req)?; let hash = auth::hash_password(&req.password)?; let org_name = req @@ -124,6 +125,7 @@ pub async fn login( jar: CookieJar, Json(req): Json, ) -> ApiResult<(CookieJar, Json)> { + password_login_enabled(&state)?; let email = req.email.trim(); let user = utopia_store::accounts::find_user_by_email(&state.pool, email).await?; // 邮箱存在与否的分支要走一样的代码路径:少了 argon2 的那一支能通过 @@ -253,3 +255,14 @@ pub async fn change_password( .await; Ok(Json(json!({ "ok": true }))) } + +fn password_login_enabled(state: &AppState) -> Result<(), AppError> { + if state.password_login { + Ok(()) + } else { + Err(AppError::invalid( + "password_login_disabled", + "Password sign-in is turned off on this deployment; sign in with SSO", + )) + } +} diff --git a/crates/utopia-server/src/api/mod.rs b/crates/utopia-server/src/api/mod.rs index 9955b2e79..161223f12 100644 --- a/crates/utopia-server/src/api/mod.rs +++ b/crates/utopia-server/src/api/mod.rs @@ -649,6 +649,9 @@ async fn jobs_noop( Ok(Json(json!({ "job_id": id }))) } +#[cfg(test)] +mod password_login_tests; + #[cfg(test)] mod rule_metadata_tests; diff --git a/crates/utopia-server/src/api/oidc_routes.rs b/crates/utopia-server/src/api/oidc_routes.rs index cd570b923..fb0ba9256 100644 --- a/crates/utopia-server/src/api/oidc_routes.rs +++ b/crates/utopia-server/src/api/oidc_routes.rs @@ -280,8 +280,8 @@ fn form_encode(s: &str) -> String { url::form_urlencoded::byte_serialize(s.as_bytes()).collect() } -pub async fn status() -> Json { - Json(json!({"enabled": config().is_ok()})) +pub async fn status(State(s): State) -> Json { + Json(json!({"enabled": config().is_ok(), "password_login": s.password_login})) } #[derive(Deserialize)] diff --git a/crates/utopia-server/src/api/password_login_tests.rs b/crates/utopia-server/src/api/password_login_tests.rs new file mode 100644 index 000000000..6516bc222 --- /dev/null +++ b/crates/utopia-server/src/api/password_login_tests.rs @@ -0,0 +1,72 @@ +//! 关掉密码登录(`UTOPIA_PASSWORD_LOGIN=false`):登录与注册都拒,登录页据状态藏起表单。 +use crate::state::AppState; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tower::ServiceExt; + +#[tokio::test] +async fn with_password_login_off_login_and_register_are_refused() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = sqlx::PgPool::connect(&url).await?; + let dir = tempfile::tempdir()?; + let cfg = utopia_core::config::AppConfig { + data_dir: dir.path().to_string_lossy().into_owned(), + password_login: false, + ..Default::default() + }; + let state = AppState::new( + pool, + &cfg, + Arc::new(utopia_search::SearchIndex::open( + &dir.path().join("search"), + )?), + "test-only".into(), + ); + let call = |method: &'static str, path: &'static str, body: Value| { + let state = state.clone(); + async move { + let request = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string()))?; + let response = super::router(state, &Default::default()) + .oneshot(request) + .await?; + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 65536).await?; + anyhow::Ok(( + status, + serde_json::from_slice::(&body).unwrap_or(Value::Null), + )) + } + }; + let credentials = + json!({"email": "ann@example.test", "password": "long-enough-1", "display_name": "Ann"}); + + let (status, body) = call("POST", "/api/v1/auth/login", credentials.clone()).await?; + assert_eq!( + (status, body["code"].clone()), + ( + StatusCode::UNPROCESSABLE_ENTITY, + json!("password_login_disabled") + ) + ); + let (status, body) = call("POST", "/api/v1/auth/register", credentials).await?; + assert_eq!( + (status, body["code"].clone()), + ( + StatusCode::UNPROCESSABLE_ENTITY, + json!("password_login_disabled") + ) + ); + let (_, body) = call("GET", "/api/v1/auth/oidc/status", Value::Null).await?; + assert_eq!(body["password_login"], json!(false)); + Ok(()) +} diff --git a/crates/utopia-server/src/state.rs b/crates/utopia-server/src/state.rs index b2161c969..00697f527 100644 --- a/crates/utopia-server/src/state.rs +++ b/crates/utopia-server/src/state.rs @@ -27,6 +27,7 @@ pub struct AppState { /// 原始文件字节的存取接缝(内容寻址,key = sha256);当前实现为本地磁盘 pub blob: Arc, pub open_registration: bool, + pub password_login: bool, /// 强制 Secure cookie(配置项);未强制时按请求的 X-Forwarded-Proto 逐次判定 pub cookie_secure: bool, /// 分块预算,见 `AppConfig::chunk_tokens` @@ -66,6 +67,7 @@ impl AppState { docs: Arc::new(crate::docs_corpus::build_index()), blob, open_registration: cfg.open_registration, + password_login: cfg.password_login, cookie_secure: cfg.cookie_secure, chunk_tokens: cfg.chunk_tokens, extract_known_in_prompt: cfg.extract_known_in_prompt, From efa9a280d01e8286c5fba93554fd7efdb73c68b1 Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Thu, 15 Oct 2026 09:58:09 +0200 Subject: [PATCH 5/7] Login page shows only SSO when password sign-in is off Signed-off-by: Bogdan Szabo --- web/src/api.ts | 3 ++- web/src/pages/Login.tsx | 20 ++++++++++++++------ web/src/pages/loginSections.test.ts | 18 ++++++++++++++++++ web/src/pages/loginSections.ts | 7 +++++++ 4 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 web/src/pages/loginSections.test.ts create mode 100644 web/src/pages/loginSections.ts diff --git a/web/src/api.ts b/web/src/api.ts index fcc73836e..798b1baf2 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -2761,7 +2761,8 @@ export const api = { /** 是否配置了单点登录(0056)。四项环境变量缺一个都是 false—— * 登录页据此决定要不要露出那个按钮 */ - oidcStatus: () => request<{ enabled: boolean }>("/api/v1/auth/oidc/status"), + oidcStatus: () => + request<{ enabled: boolean; password_login: boolean }>("/api/v1/auth/oidc/status"), /** 谁的哪个身份提供方 subject 绑定到了这个部署的哪个账号(只读 + 解绑) */ oidcIdentities: () => request<{ diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index b86382255..5e4e42006 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -25,6 +25,7 @@ import { Wordmark, } from "../ui"; import { LoginScene } from "./LoginScene"; +import { loginSections } from "./loginSections"; import { usePageTitle } from "../useTitle"; export function Login() { @@ -37,6 +38,7 @@ export function Login() { const [leaving, setLeaving] = useState(false); const queryClient = useQueryClient(); const sso = useQuery({ queryKey: ["oidc-status"], queryFn: api.oidcStatus }); + const sections = loginSections(sso.data); // 单点登录回调失败时带着代码跳回这里(`?sso_error=`),措辞按代码查 const ssoError = new URLSearchParams(window.location.search).get("sso_error"); @@ -84,6 +86,7 @@ export function Login() { className="u-card-opaque rounded-panel p-6 u-rise" style={{ animationDelay: "90ms" }} > + {sections.password && ( + )} {ssoError && (

@@ -101,6 +105,7 @@ export function Login() {

)} + {sections.password && (
{ @@ -146,14 +151,17 @@ export function Login() { : S.login.createAccount}
+ )} - {mode === "login" && sso.data?.enabled && ( + {mode === "login" && sections.sso && ( <> -
- - {S.login.orDivider} - -
+ {sections.password && ( +
+ + {S.login.orDivider} + +
+ )}