diff --git a/.env.example b/.env.example index 3bb75859b..cf0fd51d1 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,8 @@ UTOPIA_DATA_DIR=data UTOPIA_OPEN_REGISTRATION=true # 单点登录(可选)。ISSUER、CLIENT_ID、REDIRECT_URI 三项都填才生效(CLIENT_SECRET 看身份 # 提供方要不要),留空即关闭——登录页不会露出那个按钮。账号由本人在账户页绑定自己的 -# 身份(先用密码登录再走一遍 SSO),这几项只决定拿哪个身份提供方去核验令牌。 +# 身份(先用密码登录再走一遍 SSO;设了 UTOPIA_OIDC_ALLOWED_DOMAINS 则首次登录自动关联), +# 这几项只决定拿哪个身份提供方去核验令牌。 # 本地开发要用 http://localhost 上的身份提供方时,设 UTOPIA_OIDC_ALLOW_LOOPBACK_HTTP=1。 # UTOPIA_OIDC_ISSUER=https://id.example.com # UTOPIA_OIDC_CLIENT_ID= @@ -27,8 +28,20 @@ UTOPIA_OPEN_REGISTRATION=true # 受信的另一个应用可以拿它用户在同一个身份提供方刚登录到的 ID token,到 # POST /api/v1/auth/oidc/exchange 换一个 Utopia 会话(0066)。这里列它的 client id,逗号分隔; # 与 Utopia 共用一个 client 的就写 Utopia 自己的 client id。留空 = 不开放这个端点。 -# 只认本人绑定过的身份,不建号、不自动绑定;每个 ID token 只能换一次。 +# 映射与登录相同:本人绑定过的身份;设了 UTOPIA_OIDC_ALLOWED_DOMAINS 时,那些域名里的人 +# 也会像首次登录一样被关联或开户(0069)。每个 ID token 只能换一次。 # UTOPIA_OIDC_EXCHANGE_AUDIENCES= +# 这些域名(逗号分隔)里、身份提供方核实过的 email 首次单点登录时:按 email 关联已有账号, +# 没有就开一个(默认工作区的 viewer,没有密码)。留空 = 只认本人绑定过的身份。 +# 只在身份提供方对这些域名说了算时打开(0069):普通的 Google client 也会为拿公司邮箱注册的 +# 个人 Google 账号作保,最好前面再挡一层只放行本公司账号的代理。 +# 这份名单不看 UTOPIA_OPEN_REGISTRATION 与管理员的「开放注册」:关着注册照样开户。 +# 停用账号还占着的 email 不会开新号。 +# UTOPIA_OIDC_ALLOWED_DOMAINS= +# 密码登录与注册。false = 只走单点登录(登录页只剩那个按钮);身份提供方出事时改回 true。 +# 没配单点登录时这个 false 不生效。单点登录开的账号没有密码、也没法重设,改回 true 也进不去—— +# 包括经 SSO 引导出来的首个管理员。务必留一个有密码的管理员账号当后门。 +# UTOPIA_PASSWORD_LOGIN=true # 数据库连接池上限。缺省 32,与 worker 并发的缺省对齐。 # 池子小于并发时症状是请求变慢、而不是任何一处说"池子不够"——调高 worker # 并发时一并考虑这里,也别超过 Postgres 自己的 max_connections(默认 100)。 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..8a134f26e 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 super::oidc_routes::password_login(state) { + 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 b500ed798..f0773a6c0 100644 --- a/crates/utopia-server/src/api/mod.rs +++ b/crates/utopia-server/src/api/mod.rs @@ -14,7 +14,7 @@ mod kbs; mod mapping_routes; mod mcp; mod members_routes; -mod oidc_routes; +pub mod oidc_routes; mod ontology_regression_routes; pub(crate) mod ontology_routes; mod question_routes; diff --git a/crates/utopia-server/src/api/oidc_exchange_tests.rs b/crates/utopia-server/src/api/oidc_exchange_tests.rs index fdcad2628..817c68dfa 100644 --- a/crates/utopia-server/src/api/oidc_exchange_tests.rs +++ b/crates/utopia-server/src/api/oidc_exchange_tests.rs @@ -35,6 +35,48 @@ async fn exchange_maps_only_linked_active_people_and_spends_each_token_once() -> ) .route("/jwks", get(move || async move { Json(jwks) })); let server = tokio::spawn(async move { axum::serve(listener, provider).await.unwrap() }); + + // 关掉密码登录只在 SSO 配好时生效:配置读的是进程环境变量,所以和换会话放在一个测试里 + let dir = tempfile::tempdir()?; + let no_password = AppState::new( + pool.clone(), + &utopia_core::config::AppConfig { + data_dir: dir.path().to_string_lossy().into_owned(), + password_login: false, + ..Default::default() + }, + 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 = no_password.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 body = axum::body::to_bytes(response.into_body(), 65536).await?; + anyhow::Ok(serde_json::from_slice::(&body).unwrap_or(Value::Null)) + } + }; + let credentials = json!({"email": format!("{}@example.test", Uuid::now_v7()), "password": "wrong-but-long-1"}); + let password_rule = || async { + let login = call("POST", "/api/v1/auth/login", credentials.clone()).await?; + let status = call("GET", "/api/v1/auth/oidc/status", Value::Null).await?; + anyhow::Ok((login["code"].clone(), status["password_login"].clone())) + }; + assert_eq!( + password_rule().await?, + (Value::Null, json!(true)), + "without SSO the switch is ignored" + ); + // 只有这个测试读写这几个变量 std::env::set_var("UTOPIA_OIDC_ISSUER", &issuer); std::env::set_var("UTOPIA_OIDC_CLIENT_ID", "utopia-test"); @@ -44,9 +86,15 @@ async fn exchange_maps_only_linked_active_people_and_spends_each_token_once() -> ); std::env::set_var("UTOPIA_OIDC_ALLOW_LOOPBACK_HTTP", "1"); std::env::remove_var("UTOPIA_OIDC_EXCHANGE_AUDIENCES"); + assert_eq!( + password_rule().await?, + (json!("password_login_disabled"), json!(false)), + "with SSO configured the switch holds" + ); 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 +188,71 @@ 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}"); + + let accounts = |email: String| { + let pool = pool.clone(); + async move { + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM users WHERE lower(email) = lower($1)") + .bind(email) + .fetch_one(&pool) + .await + } + }; + // 停用的人换个新 subject 回来:email 还被他的停用账号占着,不开新号 + let (status, body) = exchange(sign_email("sub-comeback", &format!("{user}@example.test"), true)?).await?; + anyhow::ensure!(body["code"] == "oidc_unlinked", "deactivated email: {status} {body}"); + anyhow::ensure!(accounts(format!("{user}@example.test")).await? == 1); + + // subject 已绑在停用的账号上,新 email 开户撞车:什么都不留,内部原因也不回给调用方 + let stray = format!("{}@example.test", Uuid::now_v7().simple()); + let (status, body) = exchange(sign_email(&subject, &stray, true)?).await?; + anyhow::ensure!( + body["code"] == "oidc_unavailable" + && body["error"] == "The identity could not be mapped to an account", + "taken: {status} {body}" + ); + anyhow::ensure!(accounts(stray).await? == 0); 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 8fb4c0a93..8cfdf7034 100644 --- a/crates/utopia-server/src/api/oidc_routes.rs +++ b/crates/utopia-server/src/api/oidc_routes.rs @@ -7,7 +7,9 @@ //! (包括别的管理员)登录的路:绑定不经本人、本人看不见、改密码也断不掉,之后的 //! 操作在台账上记成被冒充的人(0014:身份来自本人)。管理员现在只能看与解绑。 //! -//! 绝不按身份提供方的 email 这类可变声明自动关联,首次登录也绝不自动开账号。 +//! 默认绝不按身份提供方的 email 这类可变声明自动关联,首次登录也不自动开账号。 +//! 例外是部署列了 `UTOPIA_OIDC_ALLOWED_DOMAINS`:那些域名里核实过的 email 首次登录时 +//! 关联已有账号或开一个(0069:身份提供方对这些域名说了算)。 //! //! **另一个应用可以拿它刚登录到的 ID token 换一个会话**(`exchange`,0066):同一个 //! 身份提供方、受信的 audience 列表、十分钟内签发、只换一次,映射规则与登录相同。 @@ -61,6 +63,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 +92,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")), }; @@ -271,8 +282,31 @@ 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": password_login(&s)})) +} + +/// 密码登录实际开没开:`UTOPIA_PASSWORD_LOGIN=false` 只在 SSO 配好时生效。 +/// 没有 SSO 还关掉密码,谁都进不来——那就当这个开关没设,与登录页同一条规则 +pub fn password_login(s: &AppState) -> bool { + s.password_login || config().is_err() +} + +/// 启动时把关掉密码登录的两种后果说出来:没配 SSO 时这个开关不生效; +/// 没有允许名单时只有绑定过身份的人进得来,新人既注册不了也开不了户 +pub fn warn_about_password_login(s: &AppState) { + if s.password_login { + return; + } + match config() { + Err(_) => { + tracing::warn!("UTOPIA_PASSWORD_LOGIN=false 被忽略:没配单点登录,关掉密码就谁都进不来") + } + Ok(c) if c.allowed_domains.is_empty() => tracing::warn!( + "密码登录已关、UTOPIA_OIDC_ALLOWED_DOMAINS 为空:只有绑定过身份的人能登录" + ), + Ok(_) => {} + } } #[derive(Deserialize)] @@ -382,6 +416,40 @@ struct Claims { #[serde(default)] azp: Option, aud: Value, + #[serde(default)] + email: Option, + /// 每个令牌都要过这里:有的身份提供方发字符串 `"true"`,严格按 bool 解析会让所有人 + /// 都登不进来。只有 `true` / `"true"` 算核实过,其它一律不算 + #[serde(default, deserialize_with = "lenient_true")] + email_verified: bool, + #[serde(default)] + name: Option, +} + +fn lenient_true<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(match Value::deserialize(d)? { + Value::Bool(b) => b, + Value::String(v) => v == "true", + _ => false, + }) +} + +/// 开户用的显示名:`name` 声明去掉首尾空白、最多 64 个字符;空的就用 email 的本地部分 +fn display_name(claim: Option<&str>, email: &str) -> String { + let name: String = claim.unwrap_or_default().trim().chars().take(64).collect(); + if name.is_empty() { + email.split('@').next().unwrap_or_default().to_string() + } else { + name + } +} + +/// 能自动开户或按 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 钥匙才用它 @@ -471,7 +539,7 @@ pub struct Exchange { /// 另一个应用(client id 在 `UTOPIA_OIDC_EXCHANGE_AUDIENCES` 里)让它的用户在同一个 /// 身份提供方登录,再拿刚到手的 ID token 换一个 Utopia 会话,以那个人的身份调这里的 -/// API。映射与 SSO 登录一样只认本人绑定过的身份;这里不开账号,也不绑定。 +/// API。映射与 SSO 登录相同:本人绑定过的身份,或者允许名单里的域名(0069)。 /// 没配 SSO 或没配受信 audience 时就当这个端点不存在 pub async fn exchange( State(s): State, @@ -524,14 +592,13 @@ 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| { + tracing::warn!(code = r.code, reason = %r.reason, "SSO exchange refused"); + AppError::invalid( + "oidc_unavailable", + "The identity could not be mapped to an account", + ) + })?; let user_id = user_id.ok_or_else(|| { AppError::invalid( "oidc_unlinked", @@ -689,15 +756,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( @@ -715,6 +774,53 @@ 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 provisioned = utopia_store::accounts::account_for_sso( + &s.pool, + &c.issuer, + &claims.sub, + &email, + &display_name(claims.name.as_deref(), &email), + ) + .await + .map_err(|e| match e { + AppError::CodedConflict { code, message } => refuse(code, message), + e => refuse("unavailable", format!("provision: {e}")), + })?; + let Some(user) = provisioned else { + tracing::warn!(%email, "单点登录:这个 email 属于一个停用的账号,不开新号"); + return Ok(None); + }; + let _ = utopia_store::audit::record( + &s.pool, + None, + user.id, + "auth.oidc_link", + "user", + Some(user.id), + json!({"issuer": c.issuer, "subject": claims.sub, "by": "allowed_domain"}), + ) + .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", @@ -862,6 +968,59 @@ pub async fn unbind( #[cfg(test)] mod tests { + #[test] + fn email_verified_is_true_only_for_true_or_the_string_true() { + let verified = |v: serde_json::Value| { + let mut claims = serde_json::json!({"sub": "p", "iat": 0, "aud": "a"}); + if !v.is_null() { + claims["email_verified"] = v; + } + serde_json::from_value::(claims) + .map(|c| c.email_verified) + .ok() + }; + let cases = [ + serde_json::json!(true), + serde_json::json!("true"), + serde_json::json!("TRUE"), + serde_json::json!(false), + serde_json::json!("false"), + serde_json::json!(1), + serde_json::Value::Null, + ]; + assert_eq!( + cases.map(verified), + [ + Some(true), + Some(true), + Some(false), + Some(false), + Some(false), + Some(false), + Some(false) + ] + ); + } + + #[test] + fn a_display_name_is_trimmed_capped_at_64_and_falls_back_to_the_local_part() { + let long = "名".repeat(70); + assert_eq!( + [ + super::display_name(Some(" Ann Lee \n"), "ann@x.test"), + super::display_name(Some(" "), "bea@x.test"), + super::display_name(None, "cid@x.test"), + super::display_name(Some(&long), "dee@x.test"), + ], + [ + "Ann Lee".to_string(), + "bea".into(), + "cid".into(), + "名".repeat(64) + ] + ); + } + #[test] fn only_local_callbacks_can_use_http() { assert!(super::secure_url("http://localhost:1516/callback", true).is_ok()); @@ -900,6 +1059,38 @@ 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(), @@ -907,6 +1098,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-server/src/auth.rs b/crates/utopia-server/src/auth.rs index fe061e70d..49ebf01c6 100644 --- a/crates/utopia-server/src/auth.rs +++ b/crates/utopia-server/src/auth.rs @@ -35,13 +35,16 @@ pub fn hash_password(password: &str) -> Result { } pub fn verify_password(password: &str, hash: &str) -> bool { - PasswordHash::new(hash) - .map(|parsed| { - Argon2::default() - .verify_password(password.as_bytes(), &parsed) - .is_ok() - }) - .unwrap_or(false) + // 单点登录开的账号存的是 `!`,解析不了。照样跑一遍 argon2(对着 dummy), + // 否则响应快得多,能据此分辨出哪些账号只走 SSO + let Ok(parsed) = PasswordHash::new(hash) else { + let dummy = PasswordHash::new(dummy_password_hash()).expect("the dummy parses"); + let _ = Argon2::default().verify_password(password.as_bytes(), &dummy); + return false; + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() } /// 「邮箱不存在」分支用的常量时间伙伴:一个用真实参数算出来的 argon2 哈希,明文是随机 diff --git a/crates/utopia-server/src/main.rs b/crates/utopia-server/src/main.rs index 03e98a174..f8f14eb9f 100644 --- a/crates/utopia-server/src/main.rs +++ b/crates/utopia-server/src/main.rs @@ -169,6 +169,7 @@ async fn main() -> anyhow::Result<()> { }; let state = AppState::new(pool.clone(), &cfg, search, jwt_secret); + api::oidc_routes::warn_about_password_login(&state); // worker 并发数:系统设置持久化,启动时装载;运行中经同一 AtomicUsize 热调 let n = utopia_store::access::worker_concurrency(&pool) 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, diff --git a/crates/utopia-store/src/accounts.rs b/crates/utopia-store/src/accounts.rs index 0e8a5f658..b665ed6c2 100644 --- a/crates/utopia-store/src/accounts.rs +++ b/crates/utopia-store/src/accounts.rs @@ -27,10 +27,30 @@ pub async fn register( open_registration: bool, ) -> AppResult { let mut tx = pool.begin().await?; + let registered = register_in( + &mut tx, + email, + password_hash, + display_name, + org_name, + open_registration, + ) + .await?; + tx.commit().await?; + Ok(registered) +} +async fn register_in( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + email: &str, + password_hash: &str, + display_name: &str, + org_name: Option<&str>, + open_registration: bool, +) -> AppResult { let existing_org: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM organizations ORDER BY created_at LIMIT 1") - .fetch_optional(&mut *tx) + .fetch_optional(&mut **tx) .await?; let result = match existing_org { @@ -40,11 +60,10 @@ pub async fn register( sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, $2)") .bind(org_id) .bind(org_name.unwrap_or("Default Organization")) - .execute(&mut *tx) + .execute(&mut **tx) .await?; - let user = - insert_user(&mut tx, org_id, email, password_hash, display_name, true).await?; + let user = insert_user(tx, org_id, email, password_hash, display_name, true).await?; let workspace: Workspace = sqlx::query_as( "INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, $3) RETURNING *", @@ -52,11 +71,11 @@ pub async fn register( .bind(Uuid::now_v7()) .bind(org_id) .bind("Default Workspace") - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; - insert_membership(&mut tx, user.id, workspace.id, Role::Owner).await?; - let kb_id = insert_general_kb(&mut tx, workspace.id, user.id).await?; + insert_membership(tx, user.id, workspace.id, Role::Owner).await?; + let kb_id = insert_general_kb(tx, workspace.id, user.id).await?; (user, workspace, Some(kb_id)) } Some((org_id,)) => { @@ -66,20 +85,19 @@ pub async fn register( "Registration is closed for this deployment. Contact your administrator.", )); } - let user = - insert_user(&mut tx, org_id, email, password_hash, display_name, false).await?; + let user = insert_user(tx, org_id, email, password_hash, display_name, false).await?; // 加入默认(最早的)工作区;异常情况下组织没有工作区则补建一个 let default_ws: Option = sqlx::query_as( "SELECT * FROM workspaces WHERE org_id = $1 ORDER BY created_at LIMIT 1", ) .bind(org_id) - .fetch_optional(&mut *tx) + .fetch_optional(&mut **tx) .await?; match default_ws { Some(ws) => { - insert_membership(&mut tx, user.id, ws.id, Role::Viewer).await?; + insert_membership(tx, user.id, ws.id, Role::Viewer).await?; (user, ws, None) } None => { @@ -89,16 +107,15 @@ pub async fn register( .bind(Uuid::now_v7()) .bind(org_id) .bind("Default Workspace") - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; - insert_membership(&mut tx, user.id, ws.id, Role::Owner).await?; + insert_membership(tx, user.id, ws.id, Role::Owner).await?; (user, ws, None) } } } }; - tx.commit().await?; let (user, workspace, general_kb) = result; Ok(Registered { user, @@ -354,3 +371,65 @@ pub async fn set_system_admin( } Ok(()) } + +/// 单点登录允许自动开户时,用身份提供方核实过的 email 找账号;找不到就按注册的 +/// 规则开一个(默认工作区的 viewer;部署里还没人时就是首个管理员),并把这个 +/// subject 绑上。开户与绑定同一个事务:绑定撞车(`taken` / `already_linked`)时 +/// 不留下一个没人能登进去的账号。 +/// +/// 这个 email 只剩一个停用的账号时返回 `None`:停用的人不能换个新号回来—— +/// email 的唯一索引只管启用中的账号,挡不住这一步,得在这里查。 +/// +/// 开出来的账号**没有可用的密码**:`!` 不是合法的哈希,密码登录永远验不过。 +pub async fn account_for_sso( + pool: &PgPool, + issuer: &str, + subject: &str, + email: &str, + display_name: &str, +) -> AppResult> { + let mut tx = pool.begin().await?; + let active: Option = sqlx::query_as( + "SELECT * FROM users WHERE lower(email) = lower($1) AND deactivated_at IS NULL", + ) + .bind(email) + .fetch_optional(&mut *tx) + .await?; + let user = match active { + Some(u) => u, + None => { + let deactivated: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM users WHERE lower(email) = lower($1))", + ) + .bind(email) + .fetch_one(&mut *tx) + .await?; + if deactivated { + return Ok(None); + } + register_in(&mut tx, email, "!", display_name, None, true) + .await? + .user + } + }; + sqlx::query("INSERT INTO oidc_identities (issuer, subject, user_id) VALUES ($1, $2, $3)") + .bind(issuer) + .bind(subject) + .bind(user.id) + .execute(&mut *tx) + .await + .map_err(|e| match &e { + // 两个约束各对应一种撞车:subject 已绑在别人身上,或者这个账号已经有一个 + sqlx::Error::Database(db) if db.is_unique_violation() => AppError::CodedConflict { + code: if db.constraint().is_some_and(|n| n.contains("user")) { + "already_linked" + } else { + "taken" + }, + message: "This SSO identity cannot be linked to that account".into(), + }, + _ => AppError::Db(e), + })?; + tx.commit().await?; + Ok(Some(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..7aa956c40 --- /dev/null +++ b/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs @@ -0,0 +1,231 @@ +//! 单点登录按核实过的 email 找账号,找不到就开一个——打在真库上。 + +use sqlx::PgPool; +use uuid::Uuid; + +const ISSUER: &str = "https://id.sso.test"; + +/// 一个比库里其它组织都早的组织,开户会落进它的默认工作区 +/// 用「最早的组织」的测试一个一个跑,组织的时间也比库里现有的都早一天: +/// 并行时谁更早说不准,中断的旧测试留下的组织也会抢走「最早」 +static EARLIEST: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +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) + SELECT $1, 'sso-test', LEAST(min(created_at), '2000-01-01') - interval '1 day' FROM organizations", + ) + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name, created_at) SELECT $1, $2, 'sso-ws', created_at FROM organizations WHERE id = $2") + .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, + ISSUER, + &format!("sub-{user}"), + &email, + "Ann", + ) + .await?; + assert_eq!(found.map(|u| u.id), Some(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 _serial = EARLIEST.lock().await; + let (org, ws) = earliest_org(&pool).await?; + let email = format!("{}-bea@sso.test", Uuid::now_v7().simple()); + + let run = async { + let subject = format!("sub-{email}"); + let opened = + utopia_store::accounts::account_for_sso(&pool, ISSUER, &subject, &email, "Bea") + .await? + .ok_or_else(|| anyhow::anyhow!("no account opened"))?; + let linked: Uuid = sqlx::query_scalar( + "SELECT user_id FROM oidc_identities WHERE issuer = $1 AND subject = $2", + ) + .bind(ISSUER) + .bind(&subject) + .fetch_one(&pool) + .await?; + assert_eq!(linked, opened.id); + 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 +} + +/// 停用的账号还占着这个 email:不开新号——否则停用对允许名单里的人形同虚设 +#[tokio::test] +async fn an_email_held_by_a_deactivated_account_opens_no_account() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let _serial = EARLIEST.lock().await; + let (org, _) = earliest_org(&pool).await?; + let email = format!("{}-cid@sso.test", Uuid::now_v7().simple()); + + let run = async { + sqlx::query( + "INSERT INTO users (id, org_id, email, display_name, password_hash, deactivated_at) + VALUES ($1, $2, $3, 'Cid', 'x', now())", + ) + .bind(Uuid::now_v7()) + .bind(org) + .bind(email.to_uppercase()) + .execute(&pool) + .await?; + let opened = utopia_store::accounts::account_for_sso( + &pool, + ISSUER, + &format!("sub-{email}"), + &email, + "Cid", + ) + .await?; + let accounts: i64 = + sqlx::query_scalar("SELECT count(*) FROM users WHERE lower(email) = $1") + .bind(&email) + .fetch_one(&pool) + .await?; + assert_eq!((opened.map(|u| u.id), accounts), (None, 1)); + Ok::<_, anyhow::Error>(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + run +} + +/// subject 已绑在别人身上:开户与绑定同一个事务,撞车就什么都不留 +#[tokio::test] +async fn a_subject_linked_to_another_account_leaves_no_new_account() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let _serial = EARLIEST.lock().await; + let (org, _) = earliest_org(&pool).await?; + let (holder, subject) = (Uuid::now_v7(), format!("sub-{}", Uuid::now_v7())); + let email = format!("{}-dee@sso.test", Uuid::now_v7().simple()); + + let run = async { + sqlx::query( + "INSERT INTO users (id, org_id, email, display_name, password_hash) + VALUES ($1, $2, $3, 'Holder', 'x')", + ) + .bind(holder) + .bind(org) + .bind(format!("{holder}@sso.test")) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO oidc_identities (issuer, subject, user_id) VALUES ($1, $2, $3)") + .bind(ISSUER) + .bind(&subject) + .bind(holder) + .execute(&pool) + .await?; + let refused = + utopia_store::accounts::account_for_sso(&pool, ISSUER, &subject, &email, "Dee").await; + let accounts: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE email = $1") + .bind(&email) + .fetch_one(&pool) + .await?; + assert!( + matches!( + &refused, + Err(utopia_core::AppError::CodedConflict { code: "taken", .. }) + ), + "{:?}", + refused.map(|u| u.map(|u| u.id)) + ); + assert_eq!(accounts, 0); + 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; diff --git a/docs/decisions/0066-another-app-may-trade-a-fresh-id-token-for-a-session.md b/docs/decisions/0066-another-app-may-trade-a-fresh-id-token-for-a-session.md index 170c3789c..2288441a4 100644 --- a/docs/decisions/0066-another-app-may-trade-a-fresh-id-token-for-a-session.md +++ b/docs/decisions/0066-another-app-may-trade-a-fresh-id-token-for-a-session.md @@ -16,6 +16,8 @@ A second application signs its users in with the same identity provider as Utopi 3. **The mapping is the login's mapping.** Only an identity the person linked themselves (`oidc_identities`) maps to an account, and only while the account is active; otherwise `oidc_unlinked`. The exchange never creates an account and never links one. An app that can exchange tokens therefore reaches no one who has not, at some point, signed in to Utopia with a password and linked their identity. + *Revised 2026-10-16:* when a deployment lists `UTOPIA_OIDC_ALLOWED_DOMAINS`, the mapping, still the login's, also links or opens an account for a verified address in a listed domain, so an exchange can reach such a person on first contact. Why, and what the deployment then trusts, is in [0069](0069-the-identity-provider-vouches-for-listed-domains.md). + 4. **Each token is spent once.** Login makes its `state` single-use through `oidc_flows`; the exchange has no state of its own, so it records the token. `oidc_exchanges` holds the SHA-256 of the whole ID token until the token can no longer be accepted, and a second exchange gets `oidc_replayed`. The row lives until `iat` plus eleven minutes: the ten-minute freshness check has no leeway, and one more minute covers a clock difference between the server and the database. It does not follow `exp`, because `exp` is validated with a 60-second leeway, and a row that ended at `exp` would be swept while the token still verified. The hash of the whole token rather than `jti`: not every provider sends `jti`, and every token has a hash. Expired rows are swept on each exchange. 5. **A forced JWKS refresh has a cooldown.** An unknown `kid` forces a fetch from the provider, so that key rotation does not lock people out. In the login callback that fetch sits behind a validated flow; the exchange is unauthenticated, so a made-up `kid` would cost the provider a request each time. Forced refreshes of one URL are at most one per minute (`FORCE_COOLDOWN`), for login and exchange alike; a refused force falls back to the cache. The moment is recorded before the fetch, so a burst of requests sends one. @@ -28,7 +30,7 @@ A second application signs its users in with the same identity provider as Utopi - **Shared client only** (the first version of #1010). The calling app had to hold Utopia's client secret, and Utopia could not tell the app's sign-ins from its own. - **Token exchange per RFC 8693** at the identity provider. It depends on the provider supporting it and being configured for it, and Utopia's sessions are not tokens the provider issues. -- **Creating or linking an account on first exchange.** The same reason login does not: an identity provider's claim would decide which account a person is (0056). +- **Creating or linking an account on first exchange.** The same reason login does not: an identity provider's claim would decide which account a person is (0056). *Revised 2026-10-16:* still rejected by default; [0069](0069-the-identity-provider-vouches-for-listed-domains.md) allows it for the domains a deployment lists, for login and exchange alike. ## Open questions diff --git a/docs/decisions/0069-the-identity-provider-vouches-for-listed-domains.md b/docs/decisions/0069-the-identity-provider-vouches-for-listed-domains.md new file mode 100644 index 000000000..9449a47e9 --- /dev/null +++ b/docs/decisions/0069-the-identity-provider-vouches-for-listed-domains.md @@ -0,0 +1,41 @@ +# 0069 · The identity provider vouches for listed domains + +- **Status**: Implemented · 2026-10-16 · #1237 · open: no per-domain role; an SSO-opened account has no password and no reset +- **Written**: 2026-10-16 (conventions in the [README](README.md)) +- **Related**: migration `0056` (an identity is linked by its owner); [0066](0066-another-app-may-trade-a-fresh-id-token-for-a-session.md) (the exchange, whose mapping this widens); [0014](0014-identity-from-the-person-scope-from-the-token.md) (identity comes from the person); [#1237](https://github.com/deeplethe/utopia/pull/1237) + +## Problem + +Until now an identity reached an account only when its owner linked it: sign in with a password, then go through SSO from the account page (0056). A company that puts Utopia behind its own identity provider wants the opposite: everyone with a company address signs in with SSO on day one, nobody keeps a Utopia password, and the password form is gone. With linking by hand only, each of those people would first need a password account, which is what the company is trying to get rid of. + +Migration 0056 rejected linking by email for a reason that still holds in general: an email is a mutable claim, and letting it decide which account a person is hands that decision to whoever controls the claim. This record says when that reason does not apply, and what the deployment then trusts. + +## Decisions + +1. **`UTOPIA_OIDC_ALLOWED_DOMAINS` lists the domains the identity provider is authoritative for.** On a login or an exchange whose subject is not linked, a token whose `email` is in a listed domain and whose `email_verified` is true is mapped by email: to the active account with that address (case-insensitive), or to a new one. The subject is then linked to it, so later sign-ins go by subject as before. The list is empty by default, and an empty list keeps 0056's rule exactly. + +2. **The trust assumption.** Listing a domain says: every token this provider signs with a verified address in that domain belongs to the person who owns that address. That holds for a provider that only knows the company's directory. It does not hold for a plain Google OAuth client: Google also signs verified tokens for consumer accounts someone registered with a company address, and that person need not be, or still be, an employee. A deployment on such a client should put a gate in front that admits only the company's Workspace accounts, or restrict the client to its organization at the provider. + +3. **The account and its link are written in one transaction** (`accounts::account_for_sso`). If the link fails (the subject is already linked to someone else, or the account already has an identity at this issuer), no account is left behind that nobody could sign in to. + +4. **A deactivated account keeps its address.** The email unique index covers active accounts only, so without a check a deactivated person in a listed domain would get a fresh viewer account on the next sign-in. When the only account holding the address is deactivated, the sign-in is refused as `unlinked` and nothing is created. + +5. **A new account is a viewer of the default workspace with no password**, the way registration places a second user; when the deployment has no one yet, it is the first admin. Its password hash is `!`, which no password verifies. Password login still runs a full argon2 check against a dummy hash for it, so the response time does not tell which accounts are SSO-only. + +6. **The domain list overrides closed registration.** `UTOPIA_OPEN_REGISTRATION=false` and the admin toggle govern self-service password sign-up. A deployment that lists domains has already decided who may come in, and it is the same deployment that closes registration to stop strangers signing up with a password. Honouring the toggle here would make provisioning useless where it is wanted. + +7. **`UTOPIA_PASSWORD_LOGIN=false` turns off password login and registration, only while SSO is configured.** Without SSO it would lock everyone out, so the server ignores it, as the login page does, and says so in a warning at startup. It also warns when password login is off and the domain list is empty, because then only people who linked an identity earlier can sign in. + +8. **Keep one admin with a password.** Turning `UTOPIA_PASSWORD_LOGIN` back on is the way in when the identity provider fails, but an SSO-opened account has no password and no reset path, and that includes a first admin bootstrapped through SSO. A deployment that turns password login off should first make sure one admin account has a password. + +9. **Small hardening of the claims.** `email_verified` is read leniently: `true` or the string `"true"` counts, anything else does not, because every token passes through this parser and a provider sending a string would otherwise stop every login. The `name` claim is trimmed and cut to 64 characters, and an empty one falls back to the address's local part. The exchange does not return the internal reason it refused a mapping; it logs it. + +## Dead ends + +- **Linking by email without a domain list.** Any provider account with a matching verified address could take over a Utopia account, including one that was registered with a password. That is 0056's objection, and it stands. +- **Respecting closed registration for new accounts.** See decision 6: it would only link people who already had a password account, which is the case the feature exists to remove. + +## Open questions + +- **Role.** Every provisioned account is a viewer of the default workspace. A mapping from provider groups to workspace roles would let a company skip assigning roles by hand. +- **Recovery for SSO-only accounts.** An SSO-opened account has no password and no reset path; if every admin is SSO-only and the provider is down, nobody can administer the deployment. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index b4d4490dc..3d6c51ebc 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -129,7 +129,8 @@ By domain; the domains are the files of [../design/](../design/README.md). **Sta |---|---|---|---| | 0014 | [Identity from the person, scope from the token](0014-identity-from-the-person-scope-from-the-token.md) | Implemented | | | 0020 | [An auditor reads it without us](0020-an-auditor-reads-it-without-us.md) | Implemented | | -| 0066 | [Another app may trade a fresh ID token for a session](0066-another-app-may-trade-a-fresh-id-token-for-a-session.md) | Implemented | | +| 0066 | [Another app may trade a fresh ID token for a session](0066-another-app-may-trade-a-fresh-id-token-for-a-session.md) | Implemented | partly (by 0069) | +| 0069 | [The identity provider vouches for listed domains](0069-the-identity-provider-vouches-for-listed-domains.md) | Implemented | | ### [interface](../design/interface.md) 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/i18n/en.ts b/web/src/i18n/en.ts index ebd6c260a..2798af29b 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -53,6 +53,7 @@ export const en = { wrong_password: "Current password is incorrect.", registration_closed: "Sign-up is closed on this deployment — ask an administrator for an account.", + password_login_disabled: "Password sign-in is turned off on this deployment. Continue with SSO.", no_chat_model: "No chat model configured yet. Set one under Administration → Models.", // 对话流里没答成的那一轮:`error` 帧带的 code(服务端的英文原句只给日志) @@ -400,6 +401,8 @@ export const en = { ssoErrors: { unlinked: "This identity isn't linked to an account. Sign in with your password and link it from your account page.", + unlinked_sso_only: + "This identity can't sign in to this deployment. Ask an administrator for access.", cancelled: "Single sign-on was cancelled.", denied: "The identity provider's answer couldn't be verified.", session: "The sign-in started in a different browser session. Try again.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 701a155e8..23eead081 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -54,6 +54,7 @@ export const zh: Strings = { bad_display_name: "显示名需要 1–64 个字符。", wrong_password: "当前密码不对。", registration_closed: "本部署已关闭自助注册——请找管理员开通账户。", + password_login_disabled: "本部署已关闭密码登录,请用单点登录。", no_chat_model: "还没有配置对话模型。到「管理 → 模型」里配一个。", // 对话流里没答成的那一轮:`error` 帧带的 code(服务端的英文原句只给日志) answer_empty: "模型没有给出回答,请再问一次。", @@ -356,6 +357,7 @@ export const zh: Strings = { ssoButton: "使用单点登录", ssoErrors: { unlinked: "这个身份还没绑定任何账号。先用密码登录,再到账户页绑定。", + unlinked_sso_only: "这个身份登不进本部署,请找管理员开通。", cancelled: "单点登录已取消。", denied: "身份提供方的应答没能通过校验。", session: "这次登录是在另一个浏览器会话里开始的,请重试。", diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index b86382255..3dd9dc761 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, ssoErrorKey } 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 && (

- {S.login.ssoErrors[ssoError] ?? S.login.ssoErrorOther} + {S.login.ssoErrors[ssoErrorKey(ssoError, sections.password)] ?? S.login.ssoErrorOther}

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