summaryrefslogtreecommitdiff
path: root/server/src/auth.rs
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/auth.rs')
-rw-r--r--server/src/auth.rs229
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])