diff --git a/src/ldap/filter/mail.rs b/src/ldap/filter/mail.rs deleted file mode 100644 index 625ca8d..0000000 --- a/src/ldap/filter/mail.rs +++ /dev/null @@ -1,248 +0,0 @@ -use ldap3_proto::{LdapFilter, LdapResultCode}; - -use std::fmt; - -use crate::db::UserRef; -use crate::ldap::LdapReturnError; - -#[derive(Clone, Debug)] -pub struct MailFilter { - pub complete: String, - pub username: String, - pub domain: String, -} - -impl fmt::Display for MailFilter { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.complete) - } -} - -impl MailFilter { - /// Tries to parse a string into an email address. - /// - /// This method is not really RFC-compliant because it merely splits by `@`, - /// but is good enough for what we do. - pub fn new(value: &str) -> Result { - let original = value; - // Here we normalize the mail attribute to lowercase to prevent - // useless mismatches. - // TODO: investigate if that's supposed to be a problem for anyone? - let value = value.to_lowercase(); - if value != original { - tracing::debug!("Normalized search email from {original} to {value}"); - } - - let mut parts = value.split('@'); - let username = parts.next().unwrap(); - let Some(domain) = parts.next() else { - tracing::debug!("Not a valid username@domain email: {value}"); - return Err(MailDomainError::InvalidMail); - }; - - if parts.next().is_some() { - tracing::debug!("Too many parts in mail address"); - return Err(MailDomainError::InvalidMail); - } - - Ok(Self { - complete: value.clone(), - username: username.to_string(), - domain: domain.to_string(), - }) - } - - /// Extract any mail filter from an LDAP search filter - /// which may contain other criteria, which we overall don't care about - /// at the moment. - pub fn from_search_filter(filter: &LdapFilter) -> Result { - let res = match filter { - LdapFilter::And(filters) | LdapFilter::Or(filters) => { - Self::from_multiple_filters(filters) - } - LdapFilter::Equality(attr, value) => { - if let Some(domain) = Self::from_equality_filter(attr, value)? { - Ok(domain) - } else { - Err(MailDomainError::NoMailFilter) - } - } - _ => Err(MailDomainError::InvalidFilter), - }; - - match &res { - Ok(mail) => tracing::debug!("Found email in search filter: {mail}"), - Err(e) => tracing::debug!("Not found email in search filter: {}", e.message()), - } - - res - } - - /// Extract any mail filter from a bunch of LDAP filters. - /// - /// Any filter that is not an equality check is discarded. - fn from_multiple_filters(filters: &[LdapFilter]) -> Result { - for filter in filters { - if let LdapFilter::Equality(attr, value) = filter { - // Here if we receive None, it means the filter was not checking for the - // `mail` attr so we continue iterating. - if let Some(found) = Self::from_equality_filter(attr, value)? { - return Ok(found); - } - } - } - - Err(MailDomainError::NoMailFilter) - } - - /// Extract any mail filter from an LDAP search equality filter extracted - /// from a global search filter. - fn from_equality_filter(attr: &str, value: &str) -> Result, MailDomainError> { - if attr != "mail" { - return Ok(None); - } - - let mail = Self::new(value)?; - Ok(Some(mail)) - } - - pub fn to_user_ref(&self) -> UserRef { - UserRef { - username: self.username.clone(), - domain: Some(self.domain.clone()), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum MailDomainError { - /// mail value not user@domain format - InvalidMail, - /// No mail filter found in the search query - NoMailFilter, - /// Filter is not And/Or/Equality for which we can find a mail filter - InvalidFilter, -} - -impl fmt::Display for MailDomainError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let msg = match self { - Self::InvalidMail => { - "No valid email address requested in mail filter in search request" - } - Self::NoMailFilter => "No mail filter found in search request", - Self::InvalidFilter => "No AND/OR/EQUALITY filter found in search request", - }; - write!(f, "{msg}") - } -} - -impl LdapReturnError for MailDomainError { - fn code(&self) -> LdapResultCode { - match self { - Self::InvalidMail => LdapResultCode::InvalidAttributeSyntax, - Self::NoMailFilter => LdapResultCode::InappropriateMatching, - Self::InvalidFilter => LdapResultCode::UnwillingToPerform, - } - } - - fn message(&self) -> String { - self.to_string() - } -} - -#[cfg(test)] -mod tests { - use crate::filter::{search_filter_and, search_filter_eq}; - - use super::*; - - #[test] - fn no_mail_filter() { - let filter = search_filter_and(&[ - search_filter_eq("uid", "a"), - search_filter_eq("objectClass", "inetOrgPerson"), - ]); - - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let e = mail_filter.unwrap_err(); - assert_eq!(e, MailDomainError::NoMailFilter); - } - - #[test] - fn invalid_filter() { - let filter = LdapFilter::Approx("mail".to_string(), "a@a.localhost".to_string()); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let e = mail_filter.unwrap_err(); - assert_eq!(e, MailDomainError::InvalidFilter); - } - - #[test] - fn invalid_mail_no_domain() { - let filter = search_filter_eq("mail", "a"); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let e = mail_filter.unwrap_err(); - assert_eq!(e, MailDomainError::InvalidMail); - } - - #[test] - fn invalid_mail_too_many_parts() { - let filter = search_filter_eq("mail", "a@a.localhost@a.localhost"); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let e = mail_filter.unwrap_err(); - assert_eq!(e, MailDomainError::InvalidMail); - } - - #[test] - fn valid_basic() { - let filter = search_filter_eq("mail", "a@a.localhost"); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let mail = mail_filter.unwrap(); - assert_eq!(mail.complete, "a@a.localhost"); - assert_eq!(mail.username, "a"); - assert_eq!(mail.domain, "a.localhost"); - } - - #[test] - fn valid_basic_normalization() { - let filter = search_filter_eq("mail", "A@A.localhost"); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let mail = mail_filter.unwrap(); - assert_eq!(mail.complete, "a@a.localhost"); - assert_eq!(mail.username, "a"); - assert_eq!(mail.domain, "a.localhost"); - } - - #[test] - fn stalwart_default() { - // (&(objectClass=inetOrgPerson)(mail=?)) - let filter = search_filter_and(&[ - search_filter_eq("mail", "a@a.localhost"), - search_filter_eq("objectClass", "inetOrgPerson"), - ]); - - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let mail = mail_filter.unwrap(); - assert_eq!(mail.complete, "a@a.localhost"); - assert_eq!(mail.username, "a"); - assert_eq!(mail.domain, "a.localhost"); - } - - // fn stalwart_lldap_example() { - // // &(|(objectClass=person)(member=cn=mail,ou=groups,dc=example,dc=org))(uid=?)) - // let filter = search_filter_and(&[ - // search_filter_eq("uid", "a"), - // search_filter_or(&[ - // search_filter_eq("objectClass", "person"), - // search_filter_eq("member", "cn=mail,ou=groups,dc=a,dc=localhost"), - // ]), - // ]); - // } -} diff --git a/src/ldap/filter/mod.rs b/src/ldap/filter/mod.rs deleted file mode 100644 index f764e1c..0000000 --- a/src/ldap/filter/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod mail; diff --git a/src/ldap/handler.rs b/src/ldap/handler.rs index 5b7336c..67ed863 100644 --- a/src/ldap/handler.rs +++ b/src/ldap/handler.rs @@ -3,7 +3,7 @@ use ldap3_proto::proto::LdapOp; use crate::db::Database; use crate::ldap::{ - LdapClientState, LdapStream, LdapStreamError, op_bind, op_ext, search_by_mail_filter, + LdapClientState, LdapStream, LdapStreamError, op_bind, op_ext, search_by_everything, }; #[tracing::instrument(name = "ldap", skip(stream, db), fields(session = %stream.session))] @@ -93,7 +93,7 @@ pub async fn ldap_handler_inner( // TODO: ctrl for pagination ctrl: _, } => { - search_by_mail_filter(stream, db, sr, msgid).await?; + search_by_everything(stream, db, sr, msgid).await?; Ok(true) } // Unsupported message diff --git a/src/ldap/mod.rs b/src/ldap/mod.rs index d72d094..59da6d2 100644 --- a/src/ldap/mod.rs +++ b/src/ldap/mod.rs @@ -2,13 +2,12 @@ mod client_state; pub use client_state::LdapClientState; mod dn; pub use dn::{Dn, MalformedDn}; -mod filter; mod handler; mod op; pub use handler::ldap_handler; pub use op::bind::{BindDn, op_bind}; pub use op::ext::op_ext; -pub use op::search::search_by_mail_filter; +pub use op::search::search_by_everything; mod return_error; pub use return_error::LdapReturnError; mod stream; diff --git a/src/ldap/op/search.rs b/src/ldap/op/search.rs index fce41dd..5d77fcf 100644 --- a/src/ldap/op/search.rs +++ b/src/ldap/op/search.rs @@ -1,12 +1,11 @@ use ldap3_proto::control::LdapControl; use ldap3_proto::proto::{ - LdapOp, LdapPartialAttribute, LdapResult, LdapSearchRequest, LdapSearchResultEntry, + LdapFilter, LdapOp, LdapPartialAttribute, LdapResult, LdapSearchRequest, LdapSearchResultEntry, }; use ldap3_proto::{LdapMsg, LdapResultCode}; use crate::db::error::BoxedError; use crate::db::{Database, DatabaseInterface, User}; -use crate::ldap::filter::mail::{MailDomainError, MailFilter}; use crate::ldap::{Dn, LdapReturnError, LdapStream, LdapStreamError, MalformedDn}; #[derive(Debug)] @@ -70,7 +69,6 @@ impl SearchDn { pub enum SearchError { Db(BoxedError), InvalidDn(InvalidSearchDn), - MailDomain(MailDomainError), } impl SearchError { @@ -100,7 +98,6 @@ impl LdapReturnError for SearchError { match self { Self::Db(_e) => LdapResultCode::Unavailable, Self::InvalidDn(e) => e.code(), - Self::MailDomain(e) => e.code(), } } @@ -108,7 +105,6 @@ impl LdapReturnError for SearchError { match self { Self::Db(e) => format!("Database error: {e}"), Self::InvalidDn(e) => e.message(), - Self::MailDomain(e) => e.message(), } } } @@ -182,7 +178,7 @@ fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResult } } -pub async fn search_by_mail_filter( +pub async fn search_by_everything( stream: &mut LdapStream, db: &Database, sr: LdapSearchRequest, @@ -196,30 +192,84 @@ pub async fn search_by_mail_filter( return Ok(()); } - let mail_filter = match MailFilter::from_search_filter(&sr.filter) { - Ok(mail_filter) => mail_filter, - Err(e) => { - SearchError::MailDomain(e) - .error_message(stream, msgid) - .await?; - return Ok(()); - } - }; - - let maybe_user = match db.get_user(&mail_filter.to_user_ref()).await { - Ok(maybe_user) => maybe_user, + let users = match search_everything(db, &sr.filter).await { + Ok(users) => users, Err(e) => { SearchError::Db(e).error_message(stream, msgid).await?; return Ok(()); } }; - if let Some(user) = maybe_user { - let entry_ctrl = (search_entry_from_user(&user, &sr.attrs), vec![]); - search_success(stream, msgid, vec![entry_ctrl]).await?; - } else { - search_success(stream, msgid, vec![]).await?; - } - + // TODO: pagination ctrl + let entries = users + .into_iter() + .map(|user| (search_entry_from_user(&user, &sr.attrs), vec![])) + .collect(); + search_success(stream, msgid, entries).await?; Ok(()) } + +// TODO: pagination +// TODO: support groups (objectclass=groupOfNames) and membership +/// A very unoptimized search algorithm for the LDAP database. +/// +/// Key information: +/// - ignores all filters that are not AND/OR/NOT/EQ (others may be added +/// if we find a valid usecase) +/// - iterates over every single entry in the database!!!!! +pub async fn search_everything(db: &Database, sr: &LdapFilter) -> Result, BoxedError> { + Ok(db + .list_all_users() + .await? + .into_iter() + .filter(|user| user_matches_filter(user, sr)) + .collect()) +} + +/// Checks if a user matches a single `LdapFilter`. +/// +/// Used recursively to see if a user matches a filter overall. +pub fn user_matches_filter(user: &User, filter: &LdapFilter) -> bool { + // TODO: we should also receive the group memberships as argument here + // so we can match that in the future + // For now, we only match mail/mailalias/uid attributes + // TODO: implement mailalias, for now it's simply mapped to the mail attribute + match filter { + LdapFilter::And(filters) => { + for sub_filter in filters { + if !user_matches_filter(user, sub_filter) { + return false; + } + } + true + } + LdapFilter::Or(filters) => { + for sub_filter in filters { + if user_matches_filter(user, sub_filter) { + return true; + } + } + false + } + LdapFilter::Not(sub_filter) => !user_matches_filter(user, sub_filter), + LdapFilter::Equality(attr, value) => match attr.as_ref() { + "uid" => user.username == *value, + // TODO: should CN be different than the mail? + "cn" | "mail" | "mailAlias" => user.mail == *value, + // TODO: group membership + "memberof" => false, + "objectClass" => matches!( + value.as_ref(), + "inetOrgPerson" | "posixAccount" | "mailAccount" | "person" + ), + _ => { + tracing::warn!("Unknown user attribute filter, considering no match: {attr}"); + false + } + }, + _ => { + tracing::warn!("Unimplemented search filter, considering no match: {filter:?}"); + false + } + } +}