diff options
Diffstat (limited to 'server')
| -rw-r--r-- | server/Cargo.lock | 169 | ||||
| -rw-r--r-- | server/Cargo.toml | 1 | ||||
| -rw-r--r-- | server/Rocket.toml | 3 | ||||
| -rw-r--r-- | server/migrations/1_initial_eyeballs.sql | 2 | ||||
| -rw-r--r-- | server/src/auth.rs | 229 | ||||
| -rw-r--r-- | server/src/main.rs | 24 |
6 files changed, 387 insertions, 41 deletions
diff --git a/server/Cargo.lock b/server/Cargo.lock index 622a32b..6cad1d0 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -282,6 +282,22 @@ dependencies = [ ] [[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] name = "cpufeatures" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -498,6 +514,7 @@ name = "eyeballs" version = "0.1.0" dependencies = [ "futures", + "ldap3", "rocket", "rocket_db_pools", "serde", @@ -556,6 +573,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1079,6 +1111,40 @@ dependencies = [ ] [[package]] +name = "lber" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df7f9fd9f64cf8f59e1a4a0753fe7d575a5b38d3d7ac5758dcee9357d83ef0a" +dependencies = [ + "bytes", + "nom", +] + +[[package]] +name = "ldap3" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166199a8207874a275144c8a94ff6eed5fcbf5c52303e4d9b4d53a0c7ac76554" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lazy_static", + "lber", + "log", + "native-tls", + "nom", + "percent-encoding", + "thiserror 1.0.69", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] name = "libc" version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1237,6 +1303,23 @@ dependencies = [ ] [[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1341,6 +1424,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1857,6 +1984,15 @@ dependencies = [ ] [[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] name = "scoped-tls" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1879,6 +2015,29 @@ dependencies = [ ] [[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1863fd3768cd83c56a7f60faa4dc0d403f1b6df0a38c3c25f44b7894e45370d5" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] name = "serde" version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2457,6 +2616,16 @@ dependencies = [ ] [[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] name = "tokio-stream" version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/server/Cargo.toml b/server/Cargo.toml index d939013..10783ba 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] futures = "0.3.31" +ldap3 = { version = "0.11.5", default-features = false, features = [ "native-tls", "tls", "tls-native", "tokio-native-tls" ] } rocket = { version = "0.5.1", features = ["json", "secrets"] } rocket_db_pools = { version = "0.2.0", features = ["sqlx_mysql"] } serde = { version = "1.0", features = ["derive"] } diff --git a/server/Rocket.toml b/server/Rocket.toml index 00ead14..4f3137a 100644 --- a/server/Rocket.toml +++ b/server/Rocket.toml @@ -1,6 +1,9 @@ [default] secret_key = "itlYmFR2vYKrOmFhupMIn/hyB6lYCCTXz4yaQX89XVg=" session_max_age_days = 7 +ldap_url = "ldap://localhost:1389" +ldap_users = "ou=users,dc=example,dc=org" +ldap_filter = "(objectClass=posixAccount)" [default.databases.eyeballs] # root is needed for tests diff --git a/server/migrations/1_initial_eyeballs.sql b/server/migrations/1_initial_eyeballs.sql index aeb1470..2e5f771 100644 --- a/server/migrations/1_initial_eyeballs.sql +++ b/server/migrations/1_initial_eyeballs.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS users ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, username VARCHAR(256) NOT NULL UNIQUE, name VARCHAR(1024) NOT NULL DEFAULT '', - active BOOLEAN NOT NULL DEFAULT 1 + dn VARCHAR(256) NULL ); CREATE TABLE IF NOT EXISTS project_users ( diff --git a/server/src/auth.rs b/server/src/auth.rs index db3a6a0..d74ec27 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -1,5 +1,6 @@ use core::net::IpAddr; -use futures::future::TryFutureExt; +use futures::{future::TryFutureExt, stream::TryStreamExt}; +use ldap3::{Ldap, LdapConnAsync}; use rocket::fairing::{self, AdHoc}; use rocket::form::Form; use rocket::http::{Cookie, CookieJar, Status}; @@ -10,8 +11,10 @@ use rocket::serde::json::{self, Json}; use rocket::serde::{Deserialize, Serialize}; use rocket::{Build, Rocket, State}; use rocket_db_pools::{sqlx, Connection, Database}; +use std::borrow::Cow; use std::collections::BTreeMap; use std::sync::Mutex; +use std::sync::OnceLock; use std::time::Instant; use time::Duration; use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; @@ -47,8 +50,11 @@ struct Login<'r> { #[derive(Debug, Deserialize)] #[allow(dead_code)] -struct AuthConfig { +struct AuthConfig<'a> { session_max_age_days: u32, + ldap_url: Cow<'a, str>, + ldap_users: Cow<'a, str>, + ldap_filter: Cow<'a, str>, } struct SessionsData { @@ -60,6 +66,10 @@ struct Sessions { data: Mutex<SessionsData>, } +struct LdapState { + ldap: OnceLock<ldap3::Ldap>, +} + #[derive(Debug, Deserialize, Serialize)] pub struct Session { pub user_id: u64, @@ -155,6 +165,27 @@ fn new_session( } } +#[cfg(not(test))] +async fn authenticate(ldap_state: &State<LdapState>, dn: &str, password: &str) -> bool { + let mut ldap = ldap_state.ldap.get().unwrap().clone(); + let maybe_result = ldap.compare(dn, "userPassword", password.as_bytes()).await; + if let Ok(result) = maybe_result { + if let Ok(is_equal) = result.equal() { + return is_equal; + } + } + false +} + +#[cfg(test)] +async fn authenticate(_ldap_state: &State<LdapState>, dn: &str, password: &str) -> bool { + match dn { + "user" => password == "password", + "other" => password == "secret", + _ => false, + } +} + #[utoipa::path( responses( (status = 200, description = "Login successful", body = api_model::StatusResponse, @@ -168,35 +199,38 @@ fn new_session( )] #[post("/login", data = "<login>")] async fn login( - auth_config: &State<AuthConfig>, + auth_config: &State<AuthConfig<'_>>, + ldap_state: &State<LdapState>, sessions: &State<Sessions>, ipaddr: IpAddr, cookies: &CookieJar<'_>, mut db: Connection<Db>, login: Form<Login<'_>>, ) -> Result<Json<api_model::StatusResponse>, Unauthorized<&'static str>> { - if login.username == "user" && login.password == "password" { - let user_id = sqlx::query!("SELECT id FROM users WHERE username=?", login.username) + let (user_id, maybe_dn) = + sqlx::query!("SELECT id,dn FROM users WHERE username=?", login.username) .fetch_one(&mut **db) - .map_ok(|r| r.id) + .map_ok(|r| (r.id, r.dn)) .map_err(|_| Unauthorized("Unknown username or password")) - .await - .unwrap(); + .await?; - let max_age = Duration::days(i64::from(auth_config.session_max_age_days)); - let session = new_session(sessions, user_id, ipaddr.to_string(), max_age); + if let Some(dn) = maybe_dn { + if authenticate(ldap_state, dn.as_str(), login.password).await { + let max_age = Duration::days(i64::from(auth_config.session_max_age_days)); + let session = new_session(sessions, user_id, ipaddr.to_string(), max_age); - let cookie = Cookie::build((SESSION_COOKIE, json::to_string(&session).unwrap())) - .path("/api") - .max_age(max_age) - .http_only(true) - .build(); + let cookie = Cookie::build((SESSION_COOKIE, json::to_string(&session).unwrap())) + .path("/api") + .max_age(max_age) + .http_only(true) + .build(); - cookies.add_private(cookie); - Ok(Json(STATUS_OK)) - } else { - Err(Unauthorized("Unknown username or password")) + cookies.add_private(cookie); + return Ok(Json(STATUS_OK)); + } } + + Err(Unauthorized("Unknown username or password")) } #[utoipa::path( @@ -248,16 +282,150 @@ fn unauthorized() -> Json<api_model::StatusResponse> { Json(STATUS_UNAUTHORIZED) } +async fn setup_ldap( + ldap_state: &LdapState, + config: &AuthConfig<'_>, +) -> Result<Ldap, ldap3::LdapError> { + let (conn, ldap) = LdapConnAsync::new(&config.ldap_url).await?; + ldap3::drive!(conn); + let ret = ldap.clone(); + ldap_state + .ldap + .set(ldap) + .expect("setup_ldap must only be called once"); + Ok(ret) +} + +#[derive(Debug)] +#[allow(dead_code)] +enum LdapOrSqlError { + LdapError(ldap3::LdapError), + SqlError(sqlx::Error), +} + +async fn sync_ldap( + ldap_state: &LdapState, + config: &AuthConfig<'_>, + db: &Db, +) -> Result<(), LdapOrSqlError> { + let mut ldap = setup_ldap(ldap_state, config) + .map_err(|e| LdapOrSqlError::LdapError(e)) + .await?; + let (entries, _) = ldap + .search( + &config.ldap_users, + ldap3::Scope::OneLevel, + &config.ldap_filter, + vec!["uid"], + ) + .map_err(|e| LdapOrSqlError::LdapError(e)) + .await? + .success() + .map_err(|e| LdapOrSqlError::LdapError(e))?; + + let mut tx = db.begin().await.unwrap(); + + // TODO: Insert/Update name as well as dn. + + let db_users = sqlx::query!("SELECT id,username,dn FROM users ORDER BY username") + .fetch(&mut *tx) + .map_ok(|r| (r.id, r.username, r.dn)) + .try_collect::<Vec<_>>() + .await + .unwrap(); + + let mut new_users: Vec<(String, String)> = Vec::new(); + let mut updated_users: Vec<(u64, String)> = Vec::new(); + let mut old_users: Vec<u64> = Vec::new(); + + let mut db_user = db_users.iter().peekable(); + + for entry in entries { + let se = ldap3::SearchEntry::construct(entry); + let uid = se.attrs.get("uid").unwrap().get(0).unwrap(); + loop { + if let Some(du) = db_user.peek() { + if du.1 == *uid { + if du.2.as_ref().is_none_or(|x| *x != se.dn) { + updated_users.push((du.0, se.dn)); + } + db_user.next(); + break; + } else if du.1 < *uid { + old_users.push(du.0); + db_user.next(); + continue; + } + } + new_users.push((uid.to_string(), se.dn)); + break; + } + } + + if !new_users.is_empty() { + let mut query_builder: sqlx::QueryBuilder<sqlx::MySql> = + sqlx::QueryBuilder::new("INSERT INTO users (username,dn) VALUES"); + + let mut first = true; + for pair in new_users { + if first { + first = false; + } else { + query_builder.push(","); + } + query_builder.push("("); + query_builder.push_bind(pair.0); + query_builder.push(","); + query_builder.push_bind(pair.1); + query_builder.push(")"); + } + + query_builder + .build() + .execute(&mut *tx) + .map_err(|e| LdapOrSqlError::SqlError(e)) + .await?; + } + + for pair in updated_users { + sqlx::query!("UPDATE users SET dn=? WHERE id=?", pair.1, pair.0) + .execute(&mut *tx) + .map_err(|e| LdapOrSqlError::SqlError(e)) + .await?; + } + + if !old_users.is_empty() { + let params = format!("?{}", ", ?".repeat(old_users.len() - 1)); + let query_str = format!("UPDATE users SET dn=NULL WHERE id IN ({})", params); + let mut query = sqlx::query(&query_str); + + for id in old_users { + query = query.bind(id); + } + + query + .execute(&mut *tx) + .map_err(|e| LdapOrSqlError::SqlError(e)) + .await?; + } + + tx.commit().map_err(|e| LdapOrSqlError::SqlError(e)).await?; + + Ok(()) +} + #[cfg(not(test))] async fn run_import(rocket: Rocket<Build>) -> fairing::Result { - match Db::fetch(&rocket) { - // TODO: Replace with ldap - Some(db) => match sqlx::query!("INSERT IGNORE INTO users (username) VALUES (?)", "user") - .execute(&**db) - .await - { - Ok(_) => Ok(rocket), - Err(_) => Err(rocket), + match rocket.state::<AuthConfig>() { + Some(config) => match rocket.state::<LdapState>() { + Some(ldap) => match Db::fetch(&rocket) { + Some(db) => match sync_ldap(ldap, config, db).await { + Ok(_) => Ok(rocket), + Err(_) => Err(rocket), + }, + None => Err(rocket), + }, + None => Err(rocket), }, None => Err(rocket), } @@ -267,8 +435,10 @@ async fn run_import(rocket: Rocket<Build>) -> fairing::Result { async fn run_import(rocket: Rocket<Build>) -> fairing::Result { match Db::fetch(&rocket) { Some(db) => match sqlx::query!( - "INSERT IGNORE INTO users (username) VALUES (?), (?)", + "INSERT IGNORE INTO users (username,dn) VALUES (?,?), (?,?)", "user", + "user", + "other", "other", ) .execute(&**db) @@ -292,6 +462,9 @@ pub fn stage(basepath: &str) -> AdHoc { }), }) .attach(AdHoc::config::<AuthConfig>()) + .manage(LdapState { + ldap: OnceLock::new(), + }) .attach(AdHoc::try_on_ignite("Auth Import", run_import)) .mount(l_basepath.clone(), routes![login, logout, status]) .register(l_basepath, catchers![unauthorized]) diff --git a/server/src/main.rs b/server/src/main.rs index 6f66866..596eb5b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -135,7 +135,7 @@ async fn get_project( projectid: u64, ) -> Result<Json<api_model::Project>, NotFound<&'static str>> { let users = sqlx::query!( - "SELECT id, username, name, active, default_role, maintainer FROM users JOIN project_users ON project_users.user=users.id WHERE project_users.project=?", + "SELECT id, username, name, dn, default_role, maintainer FROM users JOIN project_users ON project_users.user=users.id WHERE project_users.project=?", projectid) .fetch(&mut ***db) .map_ok(|r| api_model::ProjectUserEntry { @@ -143,7 +143,7 @@ async fn get_project( id: r.id, username: r.username, name: r.name, - active: r.active != 0, + active: r.dn.is_some(), }, default_role: api_model::UserReviewRole::try_from(r.default_role).unwrap(), maintainer: r.maintainer != 0, @@ -440,7 +440,7 @@ async fn reviews( let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( - "SELECT reviews.id AS id,title,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.active AS user_active FROM reviews JOIN users ON users.id=owner WHERE project=? ORDER BY id DESC LIMIT ? OFFSET ?", + "SELECT reviews.id AS id,title,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.dn AS user_dn FROM reviews JOIN users ON users.id=owner WHERE project=? ORDER BY id DESC LIMIT ? OFFSET ?", projectid, uw_limit, uw_offset) .fetch(&mut **db) .map_ok(|r| api_model::ReviewEntry { @@ -450,7 +450,7 @@ async fn reviews( id: r.user_id, username: r.username, name: r.name, - active: r.user_active != 0, + active: r.user_dn.is_some(), }, state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, @@ -497,7 +497,7 @@ async fn review( let mut projectid = 0; let mut review = sqlx::query!( - "SELECT reviews.id AS id,project,title,description,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.active AS user_active FROM reviews JOIN users ON users.id=owner WHERE reviews.id=?", + "SELECT reviews.id AS id,project,title,description,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.dn AS user_dn FROM reviews JOIN users ON users.id=owner WHERE reviews.id=?", reviewid) .fetch_one(&mut **db) .map_ok(|r| { @@ -511,7 +511,7 @@ async fn review( id: r.user_id, username: r.username, name: r.name, - active: r.user_active != 0, + active: r.user_dn.is_some(), }, users: Vec::new(), state: api_model::ReviewState::try_from(r.state).unwrap(), @@ -522,7 +522,7 @@ async fn review( .await?; let mut users = sqlx::query!( - "SELECT id,username,name,active,project_users.default_role AS role FROM users JOIN project_users ON project_users.user=id WHERE project_users.project=? ORDER BY role,username,id", + "SELECT id,username,name,dn,project_users.default_role AS role FROM users JOIN project_users ON project_users.user=id WHERE project_users.project=? ORDER BY role,username,id", projectid) .fetch(&mut **db) .map_ok(|r| api_model::ReviewUserEntry { @@ -530,7 +530,7 @@ async fn review( id: r.id, username: r.username, name: r.name, - active: r.active != 0, + active: r.dn.is_some(), }, role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) @@ -539,7 +539,7 @@ async fn review( .unwrap(); let override_users = sqlx::query!( - "SELECT id,username,name,active,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,username,id", + "SELECT id,username,name,dn,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,username,id", reviewid) .fetch(&mut **db) .map_ok(|r| api_model::ReviewUserEntry { @@ -547,7 +547,7 @@ async fn review( id: r.id, username: r.username, name: r.name, - active: r.active != 0, + active: r.dn.is_some(), }, role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) @@ -589,7 +589,7 @@ async fn users( let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( - "SELECT id,username,name,active FROM users ORDER BY username LIMIT ? OFFSET ?", + "SELECT id,username,name,dn FROM users ORDER BY username LIMIT ? OFFSET ?", uw_limit, uw_offset ) @@ -598,7 +598,7 @@ async fn users( id: r.id, username: r.username, name: r.name, - active: r.active != 0, + active: r.dn.is_some(), }) .try_collect::<Vec<_>>() .await |
