feat: Implement basic user search beyond mail attribute
This commit is contained in:
parent
ace9515f6e
commit
ec36ea9116
5 changed files with 78 additions and 278 deletions
|
|
@ -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<Self, MailDomainError> {
|
||||
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<Self, MailDomainError> {
|
||||
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<Self, MailDomainError> {
|
||||
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<Option<Self>, 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"),
|
||||
// ]),
|
||||
// ]);
|
||||
// }
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
pub mod mail;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Vec<User>, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue