diff --git a/Cargo.lock b/Cargo.lock index efe035e..bd10531 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,12 +148,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "bitflags" version = "2.13.1" @@ -518,7 +512,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "105256b138a7ed84ac1ae375870eae23be5f8fed16ca172ae5f1c1aa37bd4242" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "ldap3_lber", "nom", @@ -543,7 +537,6 @@ dependencies = [ "async-trait", "axum", "axum-extra", - "base64 0.23.1", "camino", "dn_escape", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 827b2dd..07c7928 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ argh = "0.1.19" async-trait = "0.1.92" axum = { version = "0.8.9", optional = true, features = ["macros"] } axum-extra = { version = "0.12.6", features = ["cookie"], optional = true } -base64 = "0.23.1" camino = { version = "1.2.5", features = ["serde1"] } dn_escape = { path = "vendor/dn_escape" } futures-util = { version = "0.3.34", features = ["sink"] } diff --git a/src/db/filesystem.rs b/src/db/filesystem.rs index 4c805ec..9074822 100644 --- a/src/db/filesystem.rs +++ b/src/db/filesystem.rs @@ -1,4 +1,5 @@ use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; use serde_json::Error as JsonError; use std::fmt; @@ -34,7 +35,7 @@ impl fmt::Display for FilesystemDatabaseError { impl std::error::Error for FilesystemDatabaseError {} -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct FilesystemDatabase { // TODO: Once we have common validation steps in place across DB backends, we can reduce cloning. // For now, we clone the DB on every write operation, and update it when saving to disk @@ -58,14 +59,10 @@ impl FilesystemDatabase { path: path.to_path_buf(), }; - tracing::info!("Initializing empty database in file {path}. Checking permissions..."); - db.save_self().await?; - tracing::info!("Database successfully created"); - + db.save().await?; return Ok(Database::new(db)); } - tracing::info!("Loading database from file {path}"); let s = tokio::fs::read(path) .await .map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))?; @@ -77,29 +74,13 @@ impl FilesystemDatabase { })) } - /// Attempts to save a new state of the DB, effectively switching to the new state - /// only if the save is successful. - pub async fn save(&mut self, new_db: MemoryDatabase) -> Result<(), BoxedError> { - self.save_inner(&new_db).await?; - self.inner = new_db; - Ok(()) - } - - /// Saves an in-memory DB to disk. Cannot be used directly to observe exclusive access. - async fn save_inner(&self, inner: &MemoryDatabase) -> Result<(), BoxedError> { - let s = serde_json::to_string(inner) + pub async fn save(&mut self) -> Result<(), BoxedError> { + let s = serde_json::to_string(&self) .map_err(|e| Box::new(FilesystemDatabaseError::WriteFileJson(self.path.clone(), e)))?; Ok(tokio::fs::write(&self.path, &s) .await .map_err(|e| Box::new(FilesystemDatabaseError::WriteFileIO(self.path.clone(), e)))?) } - - /// Save the database without switching to a new state. - /// - /// Used when initializing the DB, to check for permissions. - pub async fn save_self(&mut self) -> Result<(), BoxedError> { - self.save_inner(&self.inner).await - } } #[async_trait::async_trait] @@ -114,7 +95,9 @@ impl DatabaseInterface for FilesystemDatabase { return Ok(Err(e)); } - self.save(new_db).await?; + self.save().await?; + self.inner = new_db; + Ok(Ok(())) } @@ -136,7 +119,9 @@ impl DatabaseInterface for FilesystemDatabase { return Ok(Err(e)); } - self.save(new_db).await?; + self.save().await?; + self.inner = new_db; + Ok(Ok(())) } @@ -151,7 +136,9 @@ impl DatabaseInterface for FilesystemDatabase { return Ok(Err(e)); } - self.save(new_db).await?; + self.save().await?; + self.inner = new_db; + Ok(Ok(())) } diff --git a/src/db/interface.rs b/src/db/interface.rs index 36d6266..6115f39 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -70,6 +70,7 @@ pub trait DatabaseInterface: std::fmt::Debug + Send + Sync + 'static { ) -> Result, BoxedError>; async fn list_all_domains(&self) -> Result, BoxedError>; + #[expect(unused)] async fn list_all_users(&self) -> Result, BoxedError>; /// List users on a specific domain. diff --git a/src/db/memory.rs b/src/db/memory.rs index 76a53be..bae3c8e 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -14,7 +14,6 @@ pub struct MemoryDatabase { impl MemoryDatabase { #[allow(clippy::new_ret_no_self)] pub fn new() -> Database { - tracing::warn!("Using in-memory database. Data will not be saved across restarts!"); Database::new(Self::default()) } } diff --git a/src/http/login.rs b/src/http/login.rs index 7a65998..578b5ff 100644 --- a/src/http/login.rs +++ b/src/http/login.rs @@ -1,12 +1,12 @@ use axum::extract::{Form, State}; -use axum::response::{Html, IntoResponse, Response}; +use axum::response::{Html, IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use http::StatusCode; use minijinja::context; use serde::{Deserialize, Serialize}; use crate::db::{DatabaseInterface, UserRef}; -use crate::http::{HttpState, InternalRedirect, OptionalHttpSession}; +use crate::http::{HttpState, OptionalHttpSession}; #[derive(Debug, Deserialize)] pub struct LoginForm { @@ -23,13 +23,12 @@ pub enum LoginError { pub async fn login_page( State(state): State, login_error: Option, - redirect: InternalRedirect, ) -> Response { let page = state .templates .get_template("login.html") .unwrap() - .render(context! {login_error => login_error, redirect => format!("/login/?redirect={}", redirect.to_base64url())}) + .render(context! {login_error => login_error}) .unwrap(); (StatusCode::OK, Html(page)).into_response() } @@ -37,25 +36,23 @@ pub async fn login_page( pub async fn get_login( State(state): State, maybe_session: Option, - redirect: InternalRedirect, ) -> Response { if maybe_session.is_some() { - return redirect.to_redirect().into_response(); + return Redirect::to("/").into_response(); } - login_page(State(state), None, redirect).await + login_page(State(state), None).await } pub async fn post_login( State(state): State, session: Option, cookies: CookieJar, - redirect: InternalRedirect, Form(form): Form, ) -> Response { if session.is_some() { // Already logged in - return (cookies, redirect.to_redirect()).into_response(); + return (cookies, Redirect::to("/")).into_response(); } let req_user = match UserRef::from_user_maybe_domain(&form.username) { @@ -86,9 +83,9 @@ pub async fn post_login( }; let cookies = state.sessions.add_session(user, cookies); - (cookies, redirect.to_redirect()).into_response() + (cookies, Redirect::to("/")).into_response() } else { - login_page(State(state), Some(LoginError::InvalidCredentials), redirect) + login_page(State(state), Some(LoginError::InvalidCredentials)) .await .into_response() } diff --git a/src/http/logout.rs b/src/http/logout.rs index 844633d..3dc5151 100644 --- a/src/http/logout.rs +++ b/src/http/logout.rs @@ -3,7 +3,7 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use crate::http::login::{LoginError, login_page}; -use crate::http::{HttpState, InternalRedirect, OptionalHttpSession}; +use crate::http::{HttpState, OptionalHttpSession}; pub async fn logout( State(state): State, @@ -17,12 +17,7 @@ pub async fn logout( let cookies = state.sessions.remove_session(&session, cookies); ( cookies, - login_page( - State(state), - Some(LoginError::SessionInvalidated), - InternalRedirect::new(), - ) - .await, + login_page(State(state), Some(LoginError::SessionInvalidated)).await, ) .into_response() } diff --git a/src/http/mod.rs b/src/http/mod.rs index e5b8ae1..daf1507 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -17,8 +17,6 @@ mod domain; mod home; mod login; mod logout; -mod redirect; -use redirect::InternalRedirect; mod session; use session::{HttpSession, HttpSessionManager, OptionalHttpSession}; mod user; diff --git a/src/http/redirect.rs b/src/http/redirect.rs deleted file mode 100644 index 120f9c8..0000000 --- a/src/http/redirect.rs +++ /dev/null @@ -1,96 +0,0 @@ -use axum::extract::{FromRequestParts, Query}; -use axum::response::Redirect; -use base64::{Engine as _, engine::general_purpose::URL_SAFE}; -use http::request::Parts; -use serde::Deserialize; - -use crate::http::HttpState; - -/// Internal redirect to a different page. -/// -/// Is usually constructed from a base64url-encoded `redirect` -/// query string, for example in the login page. -pub struct InternalRedirect(Option); - -impl InternalRedirect { - /// Creates a new redirection from a base64url-encoded string. - /// - /// An invalid redirect is silently discarded and treated as no redirect. - pub fn from_base64url(s: &str) -> Self { - let s = match URL_SAFE.decode(s) { - Ok(s) => s, - Err(e) => { - tracing::debug!("Malformed login redirect URL, treating as empty: {e}"); - return Self::new(); - } - }; - - let s = match String::from_utf8(s) { - Ok(s) => s, - Err(e) => { - tracing::debug!("Malformed login redirect URL bytes, treating as empty: {e}"); - return Self::new(); - } - }; - - Self::from_string(s) - } - - /// Turns into a base64url string that can be added to a URL. - pub fn to_base64url(&self) -> String { - let s = self - .0 - .as_ref() - .expect("Cannot call InternalRedirect::to_base64url on an empty redirect"); - URL_SAFE.encode(s) - } - - /// Creates a new redirection from a raw string. - /// - /// If the string is empty or seems to redirect outside of our website (absolute URL), - /// the redirect is silently discarded and treated as no redirect. - pub fn from_string(s: String) -> Self { - if s.trim().is_empty() { - return Self(None); - } - - if s.starts_with("http://") || s.starts_with("https://") { - tracing::debug!("Invalid login redirect URL, treating as empty: {s}"); - return Self(None); - } - - Self(Some(s)) - } - - pub fn new() -> Self { - Self(None) - } - - pub fn to_redirect(&self) -> Redirect { - if let Some(url) = &self.0 { - Redirect::to(url) - } else { - Redirect::to("/") - } - } -} - -impl FromRequestParts for InternalRedirect { - type Rejection = !; - - async fn from_request_parts( - parts: &mut Parts, - state: &HttpState, - ) -> Result { - let Ok(f) = Query::::from_request_parts(parts, state).await else { - return Ok(Self::new()); - }; - - Ok(Self::from_base64url(&f.redirect)) - } -} - -#[derive(Clone, Debug, Deserialize)] -pub struct RedirectForm { - redirect: String, -} diff --git a/src/http/session.rs b/src/http/session.rs index fd73975..1f88874 100644 --- a/src/http/session.rs +++ b/src/http/session.rs @@ -2,13 +2,12 @@ use axum::extract::{FromRequestParts, OptionalFromRequestParts}; use axum::response::Redirect; use axum_extra::extract::cookie::{Cookie, CookieJar}; use http::request::Parts; -use http::uri::Uri; use uuid::Uuid; use std::sync::{Arc, RwLock}; use crate::db::User; -use crate::http::{HttpState, InternalRedirect}; +use crate::http::HttpState; pub const COOKIE_NAME: &str = "lldap_session"; @@ -92,22 +91,10 @@ impl FromRequestParts for HttpSession { state: &HttpState, ) -> Result { let cookies = CookieJar::from_request_parts(parts, state).await.unwrap(); - if let Some(session) = state.sessions.get_session(&cookies) { - return Ok(session); - } - - // Extract the requested URL, turn it into base64, to let the login page - // know where to redirect us. - // But first, remove the scheme/host/port from URL. - let s = Uri::builder() - .path_and_query(parts.uri.path_and_query().unwrap().clone()) - .build() - .unwrap(); - let r = InternalRedirect::from_string(s.to_string()); - Err(Redirect::to(&format!( - "/login?redirect={}", - r.to_base64url() - ))) + state + .sessions + .get_session(&cookies) + .ok_or(Redirect::to("/login")) } } diff --git a/src/ldap/handler.rs b/src/ldap/handler.rs index 5b7336c..084cfa5 100644 --- a/src/ldap/handler.rs +++ b/src/ldap/handler.rs @@ -44,7 +44,7 @@ pub async fn ldap_handler(mut stream: LdapStream, mut db: Database) { } /// Return true to keep the connection going, false to close it. -#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream, msg))] +#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream))] pub async fn ldap_handler_inner( stream: &mut LdapStream, msg: LdapMsg, diff --git a/src/ldap/op/search.rs b/src/ldap/op/search.rs index 1ef39f4..d881508 100644 --- a/src/ldap/op/search.rs +++ b/src/ldap/op/search.rs @@ -151,11 +151,9 @@ pub async fn search_success( fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry { let mut res: Vec = vec![]; for attr in req_attrs { - if let Some(attr_values) = match attr.as_str() { - "uid" => Some(vec![user.username.clone()]), - "cn" | "mail" => Some(vec![user.mail.clone()]), - // TODO: group membership - "memberof" => Some(vec![]), + if let Some(attr_value) = match attr.as_str() { + "uid" => Some(user.username.clone()), + "cn" | "mail" => Some(user.mail.clone()), _ => { tracing::warn!("Ignoring unknown attr in search query: {attr}"); None @@ -163,8 +161,9 @@ fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResult } { res.push(LdapPartialAttribute { atype: attr.clone(), - // LDAP response expects raw byte vec for each value - vals: attr_values.into_iter().map(Vec::from).collect(), + // TODO: there may be multiple values here in the future, + // eg. mailaliases + vals: vec![Vec::from(attr_value)], }); } } diff --git a/src/main.rs b/src/main.rs index 69487e3..ac42c60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,16 +18,36 @@ use ldap::ldap_handler; use listener::ListenerPath; async fn create_dummy_users(db: &mut Database) { - // TODO: customize admin password - if db.list_all_users().await.unwrap().is_empty() { - tracing::info!("Creating admin account with default `adminadmin` password"); + db.create_user(User { + username: "admin".to_string(), + domain: None, + password: "adminadmin".to_string(), + // TODO: what should we put here? + mail: "admin".to_string(), + role: Role::Admin, + }) + .await + .unwrap() + .unwrap(); + for letter in &["a", "b", "c"] { + let domain = format!("{letter}.localhost"); + db.create_domain(&domain).await.unwrap().unwrap(); db.create_user(User { - username: "admin".to_string(), - domain: None, + username: letter.to_string(), + domain: Some(domain.clone()), password: "adminadmin".to_string(), - // TODO: what should we put here? - mail: "admin".to_string(), - role: Role::Admin, + mail: format!("{letter}@{domain}"), + role: Role::DomainAdmin(domain.clone()), + }) + .await + .unwrap() + .unwrap(); + db.create_user(User { + username: format!("user{letter}"), + domain: Some(domain.clone()), + password: "adminadmin".to_string(), + mail: format!("user{letter}@{domain}"), + role: Role::User, }) .await .unwrap() @@ -54,7 +74,6 @@ async fn main() -> Result<(), GlobalError> { } else { MemoryDatabase::new() }; - tracing::info!("Database loaded successfully"); create_dummy_users(&mut db).await; #[cfg(feature = "http")] diff --git a/templates/login.html b/templates/login.html index 82ef600..11ae59a 100644 --- a/templates/login.html +++ b/templates/login.html @@ -11,7 +11,7 @@

Logout successful

{% endif %} -
+