diff options
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]) |
