diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2025-01-04 02:31:25 +0100 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2025-01-04 02:31:25 +0100 |
| commit | 0be7bb847f64367fbc64fbdea2d11684fbcdaa8f (patch) | |
| tree | 4a22ceeadadc2767437614712114c2392402dc2a /server/src/auth.rs | |
| parent | d09ffb6ee8b872c69321b3a9d992f278224741dc (diff) | |
Support ldap in auth
Non-test auth is now using ldap for account syncing and authentication.
Test auth is still using hardcoded users (user and other). But it is
now also possible to login as "other".
Diffstat (limited to 'server/src/auth.rs')
| -rw-r--r-- | server/src/auth.rs | 229 |
1 files changed, 201 insertions, 28 deletions
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]) |
