Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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)。
Expand Down
5 changes: 5 additions & 0 deletions crates/utopia-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -59,6 +63,7 @@ impl Default for AppConfig {
db_max_connections: None,
cookie_secure: false,
open_registration: true,
password_login: true,
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions crates/utopia-server/src/api/auth_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub async fn register(
jar: CookieJar,
Json(req): Json<RegisterReq>,
) -> ApiResult<(CookieJar, Json<serde_json::Value>)> {
password_login_enabled(&state)?;
validate_register(&req)?;
let hash = auth::hash_password(&req.password)?;
let org_name = req
Expand Down Expand Up @@ -124,6 +125,7 @@ pub async fn login(
jar: CookieJar,
Json(req): Json<LoginReq>,
) -> ApiResult<(CookieJar, Json<serde_json::Value>)> {
password_login_enabled(&state)?;
let email = req.email.trim();
let user = utopia_store::accounts::find_user_by_email(&state.pool, email).await?;
// 邮箱存在与否的分支要走一样的代码路径:少了 argon2 的那一支能通过
Expand Down Expand Up @@ -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",
))
}
}
2 changes: 1 addition & 1 deletion crates/utopia-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
109 changes: 109 additions & 0 deletions crates/utopia-server/src/api/oidc_exchange_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Value>(&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");
Expand All @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading