Compare commits

...
14 changed files with 189 additions and 67 deletions

9
Cargo.lock generated
View file

@ -148,6 +148,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.1" version = "2.13.1"
@ -512,7 +518,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "105256b138a7ed84ac1ae375870eae23be5f8fed16ca172ae5f1c1aa37bd4242" checksum = "105256b138a7ed84ac1ae375870eae23be5f8fed16ca172ae5f1c1aa37bd4242"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"ldap3_lber", "ldap3_lber",
"nom", "nom",
@ -537,6 +543,7 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"base64 0.23.1",
"camino", "camino",
"dn_escape", "dn_escape",
"futures-util", "futures-util",

View file

@ -8,6 +8,7 @@ argh = "0.1.19"
async-trait = "0.1.92" async-trait = "0.1.92"
axum = { version = "0.8.9", optional = true, features = ["macros"] } axum = { version = "0.8.9", optional = true, features = ["macros"] }
axum-extra = { version = "0.12.6", features = ["cookie"], optional = true } axum-extra = { version = "0.12.6", features = ["cookie"], optional = true }
base64 = "0.23.1"
camino = { version = "1.2.5", features = ["serde1"] } camino = { version = "1.2.5", features = ["serde1"] }
dn_escape = { path = "vendor/dn_escape" } dn_escape = { path = "vendor/dn_escape" }
futures-util = { version = "0.3.34", features = ["sink"] } futures-util = { version = "0.3.34", features = ["sink"] }

View file

@ -1,5 +1,4 @@
use camino::{Utf8Path, Utf8PathBuf}; use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Error as JsonError; use serde_json::Error as JsonError;
use std::fmt; use std::fmt;
@ -35,7 +34,7 @@ impl fmt::Display for FilesystemDatabaseError {
impl std::error::Error for FilesystemDatabaseError {} impl std::error::Error for FilesystemDatabaseError {}
#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[derive(Clone, Debug, Default)]
pub struct FilesystemDatabase { pub struct FilesystemDatabase {
// TODO: Once we have common validation steps in place across DB backends, we can reduce cloning. // 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 // For now, we clone the DB on every write operation, and update it when saving to disk
@ -59,10 +58,14 @@ impl FilesystemDatabase {
path: path.to_path_buf(), path: path.to_path_buf(),
}; };
db.save().await?; tracing::info!("Initializing empty database in file {path}. Checking permissions...");
db.save_self().await?;
tracing::info!("Database successfully created");
return Ok(Database::new(db)); return Ok(Database::new(db));
} }
tracing::info!("Loading database from file {path}");
let s = tokio::fs::read(path) let s = tokio::fs::read(path)
.await .await
.map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))?; .map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))?;
@ -74,13 +77,29 @@ impl FilesystemDatabase {
})) }))
} }
pub async fn save(&mut self) -> Result<(), BoxedError> { /// Attempts to save a new state of the DB, effectively switching to the new state
let s = serde_json::to_string(&self) /// 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)
.map_err(|e| Box::new(FilesystemDatabaseError::WriteFileJson(self.path.clone(), e)))?; .map_err(|e| Box::new(FilesystemDatabaseError::WriteFileJson(self.path.clone(), e)))?;
Ok(tokio::fs::write(&self.path, &s) Ok(tokio::fs::write(&self.path, &s)
.await .await
.map_err(|e| Box::new(FilesystemDatabaseError::WriteFileIO(self.path.clone(), e)))?) .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] #[async_trait::async_trait]
@ -95,9 +114,7 @@ impl DatabaseInterface for FilesystemDatabase {
return Ok(Err(e)); return Ok(Err(e));
} }
self.save().await?; self.save(new_db).await?;
self.inner = new_db;
Ok(Ok(())) Ok(Ok(()))
} }
@ -119,9 +136,7 @@ impl DatabaseInterface for FilesystemDatabase {
return Ok(Err(e)); return Ok(Err(e));
} }
self.save().await?; self.save(new_db).await?;
self.inner = new_db;
Ok(Ok(())) Ok(Ok(()))
} }
@ -136,9 +151,7 @@ impl DatabaseInterface for FilesystemDatabase {
return Ok(Err(e)); return Ok(Err(e));
} }
self.save().await?; self.save(new_db).await?;
self.inner = new_db;
Ok(Ok(())) Ok(Ok(()))
} }

View file

@ -70,7 +70,6 @@ pub trait DatabaseInterface: std::fmt::Debug + Send + Sync + 'static {
) -> Result<Result<(), UserCreationError>, BoxedError>; ) -> Result<Result<(), UserCreationError>, BoxedError>;
async fn list_all_domains(&self) -> Result<Vec<Domain>, BoxedError>; async fn list_all_domains(&self) -> Result<Vec<Domain>, BoxedError>;
#[expect(unused)]
async fn list_all_users(&self) -> Result<Vec<User>, BoxedError>; async fn list_all_users(&self) -> Result<Vec<User>, BoxedError>;
/// List users on a specific domain. /// List users on a specific domain.

View file

@ -14,6 +14,7 @@ pub struct MemoryDatabase {
impl MemoryDatabase { impl MemoryDatabase {
#[allow(clippy::new_ret_no_self)] #[allow(clippy::new_ret_no_self)]
pub fn new() -> Database { pub fn new() -> Database {
tracing::warn!("Using in-memory database. Data will not be saved across restarts!");
Database::new(Self::default()) Database::new(Self::default())
} }
} }

View file

@ -1,12 +1,12 @@
use axum::extract::{Form, State}; use axum::extract::{Form, State};
use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::response::{Html, IntoResponse, Response};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use http::StatusCode; use http::StatusCode;
use minijinja::context; use minijinja::context;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::db::{DatabaseInterface, UserRef}; use crate::db::{DatabaseInterface, UserRef};
use crate::http::{HttpState, OptionalHttpSession}; use crate::http::{HttpState, InternalRedirect, OptionalHttpSession};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct LoginForm { pub struct LoginForm {
@ -23,12 +23,13 @@ pub enum LoginError {
pub async fn login_page( pub async fn login_page(
State(state): State<HttpState>, State(state): State<HttpState>,
login_error: Option<LoginError>, login_error: Option<LoginError>,
redirect: InternalRedirect,
) -> Response { ) -> Response {
let page = state let page = state
.templates .templates
.get_template("login.html") .get_template("login.html")
.unwrap() .unwrap()
.render(context! {login_error => login_error}) .render(context! {login_error => login_error, redirect => format!("/login/?redirect={}", redirect.to_base64url())})
.unwrap(); .unwrap();
(StatusCode::OK, Html(page)).into_response() (StatusCode::OK, Html(page)).into_response()
} }
@ -36,23 +37,25 @@ pub async fn login_page(
pub async fn get_login( pub async fn get_login(
State(state): State<HttpState>, State(state): State<HttpState>,
maybe_session: Option<OptionalHttpSession>, maybe_session: Option<OptionalHttpSession>,
redirect: InternalRedirect,
) -> Response { ) -> Response {
if maybe_session.is_some() { if maybe_session.is_some() {
return Redirect::to("/").into_response(); return redirect.to_redirect().into_response();
} }
login_page(State(state), None).await login_page(State(state), None, redirect).await
} }
pub async fn post_login( pub async fn post_login(
State(state): State<HttpState>, State(state): State<HttpState>,
session: Option<OptionalHttpSession>, session: Option<OptionalHttpSession>,
cookies: CookieJar, cookies: CookieJar,
redirect: InternalRedirect,
Form(form): Form<LoginForm>, Form(form): Form<LoginForm>,
) -> Response { ) -> Response {
if session.is_some() { if session.is_some() {
// Already logged in // Already logged in
return (cookies, Redirect::to("/")).into_response(); return (cookies, redirect.to_redirect()).into_response();
} }
let req_user = match UserRef::from_user_maybe_domain(&form.username) { let req_user = match UserRef::from_user_maybe_domain(&form.username) {
@ -83,9 +86,9 @@ pub async fn post_login(
}; };
let cookies = state.sessions.add_session(user, cookies); let cookies = state.sessions.add_session(user, cookies);
(cookies, Redirect::to("/")).into_response() (cookies, redirect.to_redirect()).into_response()
} else { } else {
login_page(State(state), Some(LoginError::InvalidCredentials)) login_page(State(state), Some(LoginError::InvalidCredentials), redirect)
.await .await
.into_response() .into_response()
} }

View file

@ -3,7 +3,7 @@ use axum::response::{IntoResponse, Redirect, Response};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use crate::http::login::{LoginError, login_page}; use crate::http::login::{LoginError, login_page};
use crate::http::{HttpState, OptionalHttpSession}; use crate::http::{HttpState, InternalRedirect, OptionalHttpSession};
pub async fn logout( pub async fn logout(
State(state): State<HttpState>, State(state): State<HttpState>,
@ -17,7 +17,12 @@ pub async fn logout(
let cookies = state.sessions.remove_session(&session, cookies); let cookies = state.sessions.remove_session(&session, cookies);
( (
cookies, cookies,
login_page(State(state), Some(LoginError::SessionInvalidated)).await, login_page(
State(state),
Some(LoginError::SessionInvalidated),
InternalRedirect::new(),
)
.await,
) )
.into_response() .into_response()
} }

View file

@ -17,6 +17,8 @@ mod domain;
mod home; mod home;
mod login; mod login;
mod logout; mod logout;
mod redirect;
use redirect::InternalRedirect;
mod session; mod session;
use session::{HttpSession, HttpSessionManager, OptionalHttpSession}; use session::{HttpSession, HttpSessionManager, OptionalHttpSession};
mod user; mod user;

96
src/http/redirect.rs Normal file
View file

@ -0,0 +1,96 @@
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<String>);
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<HttpState> for InternalRedirect {
type Rejection = !;
async fn from_request_parts(
parts: &mut Parts,
state: &HttpState,
) -> Result<Self, Self::Rejection> {
let Ok(f) = Query::<RedirectForm>::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,
}

View file

@ -2,12 +2,13 @@ use axum::extract::{FromRequestParts, OptionalFromRequestParts};
use axum::response::Redirect; use axum::response::Redirect;
use axum_extra::extract::cookie::{Cookie, CookieJar}; use axum_extra::extract::cookie::{Cookie, CookieJar};
use http::request::Parts; use http::request::Parts;
use http::uri::Uri;
use uuid::Uuid; use uuid::Uuid;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use crate::db::User; use crate::db::User;
use crate::http::HttpState; use crate::http::{HttpState, InternalRedirect};
pub const COOKIE_NAME: &str = "lldap_session"; pub const COOKIE_NAME: &str = "lldap_session";
@ -91,10 +92,22 @@ impl FromRequestParts<HttpState> for HttpSession {
state: &HttpState, state: &HttpState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
let cookies = CookieJar::from_request_parts(parts, state).await.unwrap(); let cookies = CookieJar::from_request_parts(parts, state).await.unwrap();
state if let Some(session) = state.sessions.get_session(&cookies) {
.sessions return Ok(session);
.get_session(&cookies) }
.ok_or(Redirect::to("/login"))
// 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()
)))
} }
} }

View file

@ -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. /// Return true to keep the connection going, false to close it.
#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream))] #[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream, msg))]
pub async fn ldap_handler_inner( pub async fn ldap_handler_inner(
stream: &mut LdapStream, stream: &mut LdapStream,
msg: LdapMsg, msg: LdapMsg,

View file

@ -151,9 +151,11 @@ pub async fn search_success(
fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry { fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry {
let mut res: Vec<LdapPartialAttribute> = vec![]; let mut res: Vec<LdapPartialAttribute> = vec![];
for attr in req_attrs { for attr in req_attrs {
if let Some(attr_value) = match attr.as_str() { if let Some(attr_values) = match attr.as_str() {
"uid" => Some(user.username.clone()), "uid" => Some(vec![user.username.clone()]),
"cn" | "mail" => Some(user.mail.clone()), "cn" | "mail" => Some(vec![user.mail.clone()]),
// TODO: group membership
"memberof" => Some(vec![]),
_ => { _ => {
tracing::warn!("Ignoring unknown attr in search query: {attr}"); tracing::warn!("Ignoring unknown attr in search query: {attr}");
None None
@ -161,9 +163,8 @@ fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResult
} { } {
res.push(LdapPartialAttribute { res.push(LdapPartialAttribute {
atype: attr.clone(), atype: attr.clone(),
// TODO: there may be multiple values here in the future, // LDAP response expects raw byte vec for each value
// eg. mailaliases vals: attr_values.into_iter().map(Vec::from).collect(),
vals: vec![Vec::from(attr_value)],
}); });
} }
} }

View file

@ -18,6 +18,9 @@ use ldap::ldap_handler;
use listener::ListenerPath; use listener::ListenerPath;
async fn create_dummy_users(db: &mut Database) { 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 { db.create_user(User {
username: "admin".to_string(), username: "admin".to_string(),
domain: None, domain: None,
@ -29,29 +32,6 @@ async fn create_dummy_users(db: &mut Database) {
.await .await
.unwrap() .unwrap()
.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: letter.to_string(),
domain: Some(domain.clone()),
password: "adminadmin".to_string(),
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()
.unwrap();
} }
} }
@ -74,6 +54,7 @@ async fn main() -> Result<(), GlobalError> {
} else { } else {
MemoryDatabase::new() MemoryDatabase::new()
}; };
tracing::info!("Database loaded successfully");
create_dummy_users(&mut db).await; create_dummy_users(&mut db).await;
#[cfg(feature = "http")] #[cfg(feature = "http")]

View file

@ -11,7 +11,7 @@
<p>Logout successful</p> <p>Logout successful</p>
</div> </div>
{% endif %} {% endif %}
<form method="POST" action="/login"> <form method="POST" action="{{ login_url }}">
<div id="login-line"> <div id="login-line">
<img src="/assets/img/user.svg"> <img src="/assets/img/user.svg">
<input type="text" name="username" id="username" placeholder="Username"> <input type="text" name="username" id="username" placeholder="Username">