From 93e925a635ff0bbfb450526647a38f1463548133 Mon Sep 17 00:00:00 2001 From: Mauro D Date: Thu, 1 Jun 2023 17:08:48 +0000 Subject: [PATCH] Directory implementation - part 3 --- crates/directory/Cargo.toml | 10 + crates/directory/src/config.rs | 90 +++++++-- crates/directory/src/ldap/config.rs | 4 +- crates/directory/src/ldap/lookup.rs | 23 ++- crates/directory/src/lib.rs | 125 +++++++----- crates/directory/src/memory/config.rs | 100 ++++++++++ crates/directory/src/memory/lookup.rs | 115 +++++++++++ crates/directory/src/memory/mod.rs | 20 ++ crates/directory/src/secret.rs | 159 +++++++++++++++ crates/directory/src/sql/config.rs | 16 +- crates/directory/src/sql/lookup.rs | 10 +- crates/directory/src/sql/mod.rs | 1 + tests/resources/ldap.cfg | 116 +++++++++++ tests/src/directory/imap.rs | 6 +- tests/src/directory/ldap.rs | 195 ++++++++++++++++++ tests/src/directory/mod.rs | 82 ++++++-- tests/src/directory/smtp.rs | 2 +- tests/src/directory/sql.rs | 277 +++++++++++++++++++++++--- 18 files changed, 1212 insertions(+), 139 deletions(-) create mode 100644 crates/directory/src/memory/config.rs create mode 100644 crates/directory/src/memory/lookup.rs create mode 100644 crates/directory/src/memory/mod.rs create mode 100644 crates/directory/src/secret.rs create mode 100644 tests/resources/ldap.cfg diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 77847798..96c55123 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -7,7 +7,9 @@ resolver = "2" [dependencies] utils = { path = "../utils" } smtp-proto = { git = "https://github.com/stalwartlabs/smtp-proto" } +mail-parser = { git = "https://github.com/stalwartlabs/mail-parser", features = ["full_encoding", "serde_support", "ludicrous_mode"] } mail-send = { git = "https://github.com/stalwartlabs/mail-send", default-features = false, features = ["cram-md5", "skip-ehlo"] } +mail-builder = { git = "https://github.com/stalwartlabs/mail-builder", features = ["ludicrous_mode"] } tokio = { version = "1.23", features = ["net"] } tokio-rustls = { version = "0.24.0"} rustls = "0.21.0" @@ -18,6 +20,14 @@ async-trait = "0.1.68" ahash = { version = "0.8" } tracing = "0.1" lru-cache = "0.1.2" +pwhash = "1" +password-hash = "0.5.0" +argon2 = "0.5.0" +pbkdf2 = {version = "0.12.1", features = ["simple"] } +scrypt = "0.11.0" +sha1 = "0.10.5" +sha2 = "0.10.6" +md5 = "0.7.0" [dev-dependencies] tokio = { version = "1.23", features = ["full"] } diff --git a/crates/directory/src/config.rs b/crates/directory/src/config.rs index 0a0bf3a8..af609791 100644 --- a/crates/directory/src/config.rs +++ b/crates/directory/src/config.rs @@ -1,37 +1,89 @@ use bb8::{ManageConnection, Pool}; -use std::{sync::Arc, time::Duration}; +use std::{ + fs::File, + io::{BufRead, BufReader}, + sync::Arc, + time::Duration, +}; use utils::config::Config; -use ahash::AHashMap; +use ahash::{AHashMap, AHashSet}; use crate::{ - imap::ImapDirectory, ldap::LdapDirectory, smtp::SmtpDirectory, sql::SqlDirectory, Directory, + imap::ImapDirectory, ldap::LdapDirectory, memory::MemoryDirectory, smtp::SmtpDirectory, + sql::SqlDirectory, DirectoryConfig, Lookup, }; pub trait ConfigDirectory { - fn parse_directory(&self) -> utils::config::Result>>; + fn parse_directory(&self) -> utils::config::Result; } impl ConfigDirectory for Config { - fn parse_directory(&self) -> utils::config::Result>> { - let mut directories = AHashMap::new(); + fn parse_directory(&self) -> utils::config::Result { + let mut config = DirectoryConfig { + directories: AHashMap::new(), + lookups: AHashMap::new(), + }; for id in self.sub_keys("directory") { - directories.insert( - id.to_string(), - match self.value_require(("directory", id, "protocol"))? { - "ldap" => LdapDirectory::from_config(self, ("directory", id))?, - "sql" => SqlDirectory::from_config(self, ("directory", id))?, - "imap" => ImapDirectory::from_config(self, ("directory", id))?, - "smtp" => SmtpDirectory::from_config(self, ("directory", id), false)?, - "lmtp" => SmtpDirectory::from_config(self, ("directory", id), true)?, - unknown => { - return Err(format!("Unknown directory type: {unknown:?}")); + // Parse directory + let protocol = self.value_require(("directory", id, "protocol"))?; + let directory = match protocol { + "ldap" => LdapDirectory::from_config(self, ("directory", id))?, + "sql" => SqlDirectory::from_config(self, ("directory", id))?, + "imap" => ImapDirectory::from_config(self, ("directory", id))?, + "smtp" => SmtpDirectory::from_config(self, ("directory", id), false)?, + "lmtp" => SmtpDirectory::from_config(self, ("directory", id), true)?, + "memory" => MemoryDirectory::from_config(self, ("directory", id))?, + unknown => { + return Err(format!("Unknown directory type: {unknown:?}")); + } + }; + + // Parse lookups + let is_remote = protocol != "memory"; + for lookup_id in self.sub_keys(("directory", id, "lookup")) { + let lookup = if is_remote { + Lookup::Directory { + directory: directory.clone(), + query: self + .value_require(("directory", id, "lookup", lookup_id))? + .to_string(), } - }, - ); + } else { + let mut list = AHashSet::new(); + for (_, value) in self.values(("directory", id, "lookup", lookup_id)) { + if let Some(path) = value.strip_prefix("file://") { + for line in BufReader::new(File::open(path).map_err(|err| { + format!( + "Failed to read file {path:?} for list {id}/{lookup_id}: {err}" + ) + })?) + .lines() + { + let line_ = line.map_err(|err| { + format!("Failed to read file {path:?} for list {id}/{lookup_id}: {err}") + })?; + let line = line_.trim(); + if !line.is_empty() { + list.insert(line.to_string()); + } + } + } else { + list.insert(value.to_string()); + } + } + + Lookup::List { list } + }; + config + .lookups + .insert(format!("{id}/{lookup_id}"), Arc::new(lookup)); + } + + config.directories.insert(id.to_string(), directory); } - Ok(directories) + Ok(config) } } diff --git a/crates/directory/src/ldap/config.rs b/crates/directory/src/ldap/config.rs index fbda4fba..f0cd3dcb 100644 --- a/crates/directory/src/ldap/config.rs +++ b/crates/directory/src/ldap/config.rs @@ -35,7 +35,7 @@ impl LdapDirectory { ); let mut mappings = LdapMappings { - base_dn: config.value_require((&prefix, "address"))?.to_string(), + base_dn: config.value_require((&prefix, "base-dn"))?.to_string(), filter_login: LdapFilter::from_config(config, (&prefix, "filter.login"))?, filter_name: LdapFilter::from_config(config, (&prefix, "filter.name"))?, filter_email: LdapFilter::from_config(config, (&prefix, "filter.email"))?, @@ -73,7 +73,7 @@ impl LdapDirectory { .map(|(_, v)| v.to_string()) .collect(), attr_quota: config - .values((&prefix, "attributes.")) + .values((&prefix, "attributes.quota")) .map(|(_, v)| v.to_string()) .collect(), attrs_principal: vec!["objectClass".to_string()], diff --git a/crates/directory/src/ldap/lookup.rs b/crates/directory/src/ldap/lookup.rs index af610bb3..906b2dc5 100644 --- a/crates/directory/src/ldap/lookup.rs +++ b/crates/directory/src/ldap/lookup.rs @@ -21,7 +21,7 @@ impl Directory for LdapDirectory { .await { Ok(Some(principal)) => { - if principal.secret.as_ref().map_or(false, |s| s == secret) { + if principal.verify_secret(secret) { Ok(Some(principal)) } else { Ok(None) @@ -49,7 +49,7 @@ impl Directory for LdapDirectory { let mut ids = Vec::with_capacity(principal.member_of.len()); for group in &principal.member_of { let (rs, _res) = if group.contains('=') { - conn.search(group, Scope::Base, "", &self.mappings.attr_id) + conn.search(group, Scope::Base, "objectClass=*", &self.mappings.attr_id) .await? .success()? } else { @@ -126,11 +126,12 @@ impl Directory for LdapDirectory { let mut ids = Vec::new(); for entry in rs { let entry = SearchEntry::construct(entry); - for attr in &self.mappings.attr_id { + 'outer: for attr in &self.mappings.attr_id { if let Some(values) = entry.attrs.get(attr) { for id in values { if let Ok(id) = id.parse() { ids.push(id); + break 'outer; } } } @@ -279,16 +280,20 @@ impl LdapMappings { ..Default::default() }; for (attr, value) in entry.attrs { - if self.attr_id.contains(&attr) { - if let Ok(id) = value.into_iter().next().unwrap_or_default().parse() { - principal.id = id; + if let Some(idx) = self.attr_id.iter().position(|a| a == &attr) { + if principal.id == u32::MAX || idx == 0 { + if let Ok(id) = value.into_iter().next().unwrap_or_default().parse() { + principal.id = id; + } } } else if self.attr_name.contains(&attr) { principal.name = value.into_iter().next().unwrap_or_default(); } else if self.attr_secret.contains(&attr) { - principal.secret = value.into_iter().next(); - } else if self.attr_description.contains(&attr) { - principal.description = value.into_iter().next(); + principal.secrets.extend(value); + } else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) { + if principal.description.is_none() || idx == 0 { + principal.description = value.into_iter().next(); + } } else if self.attr_groups.contains(&attr) { principal.member_of.extend(value); } else if self.attr_quota.contains(&attr) { diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index bb6bbd2d..a551f56f 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -1,3 +1,6 @@ +use std::{fmt::Debug, sync::Arc}; + +use ahash::{AHashMap, AHashSet}; use bb8::RunError; use imap::ImapError; use ldap3::LdapError; @@ -6,21 +9,23 @@ use mail_send::Credentials; pub mod config; pub mod imap; pub mod ldap; +pub mod memory; +pub mod secret; pub mod smtp; pub mod sql; -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Principal { pub id: u32, pub name: String, - pub secret: Option, + pub secrets: Vec, pub typ: Type, pub description: Option, pub quota: u32, pub member_of: Vec, } -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum Type { Individual, Group, @@ -52,6 +57,72 @@ pub trait Directory: Sync + Send { async fn vrfy(&self, address: &str) -> Result>; async fn expn(&self, address: &str) -> Result>; async fn query(&self, query: &str, params: &[&str]) -> Result; + + fn type_name(&self) -> &'static str { + std::any::type_name::() + } +} + +#[derive(Clone)] +pub enum Lookup { + Directory { + directory: Arc, + query: String, + }, + List { + list: AHashSet, + }, +} + +impl Lookup { + pub async fn contains(&self, item: &str) -> Option { + match self { + Lookup::Directory { directory, query } => match directory.query(query, &[item]).await { + Ok(result) => result.into(), + Err(_) => None, + }, + Lookup::List { list } => list.contains(item).into(), + } + } +} + +impl PartialEq for Lookup { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Lookup::Directory { query, .. }, Lookup::Directory { query: other, .. }) => { + query == other + } + (Lookup::List { list }, Lookup::List { list: other }) => list == other, + _ => false, + } + } +} + +impl Eq for Lookup {} + +impl Debug for dyn Directory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Directory") + .field("type", &self.type_name()) + .finish() + } +} + +impl Debug for Lookup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Directory { query, .. } => { + f.debug_struct("Directory").field("query", query).finish() + } + Self::List { list } => f.debug_struct("List").field("list", list).finish(), + } + } +} + +#[derive(Default, Clone)] +pub struct DirectoryConfig { + pub directories: AHashMap>, + pub lookups: AHashMap>, } pub type Result = std::result::Result; @@ -161,51 +232,3 @@ impl DirectoryError { DirectoryError::TimedOut } } - -#[cfg(test)] -mod tests { - use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry}; - - use crate::ldap::{Bind, LdapConnectionManager}; - - #[tokio::test] - async fn ldap() { - let manager = LdapConnectionManager::new( - "ldap://localhost:3893".to_string(), - LdapConnSettings::new(), - Bind::new( - "cn=serviceuser,ou=svcaccts,dc=example,dc=com".into(), - "mysecret".into(), - ) - .into(), - ); - let pool = bb8::Pool::builder() - .min_idle(None) - .max_size(10) - .max_lifetime(std::time::Duration::from_secs(30 * 60).into()) - .idle_timeout(std::time::Duration::from_secs(10 * 60).into()) - .connection_timeout(std::time::Duration::from_secs(30)) - .test_on_check_out(true) - .build(manager) - .await - .unwrap(); - - let mut ldap = pool.get().await.unwrap(); - - let (rs, _res) = ldap - .search( - "dc=example,dc=com", - Scope::Subtree, - "(&(objectClass=posixAccount)(cn=johndoe))", - vec!["cocomiel", "cn", "uidNumber"], //Vec::::new(), - ) - .await - .unwrap() - .success() - .unwrap(); - for entry in rs { - println!("{:#?}", SearchEntry::construct(entry)); - } - ldap.unbind().await.unwrap() - } -} diff --git a/crates/directory/src/memory/config.rs b/crates/directory/src/memory/config.rs new file mode 100644 index 00000000..b6deb626 --- /dev/null +++ b/crates/directory/src/memory/config.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use utils::config::{utils::AsKey, Config}; + +use crate::{Directory, Principal, Type}; + +use super::{EmailType, MemoryDirectory}; + +impl MemoryDirectory { + pub fn from_config( + config: &Config, + prefix: impl AsKey, + ) -> utils::config::Result> { + let prefix = prefix.as_key(); + let mut directory = MemoryDirectory::default(); + + for lookup_id in config.sub_keys((prefix.as_str(), "users")) { + let id = directory.principals.len() as u32; + let name = config + .value_require((prefix.as_str(), "users", lookup_id, "name"))? + .to_string(); + directory.names.insert(name.clone(), id); + directory.principals.push(Principal { + id, + name, + secrets: config + .values((prefix.as_str(), "users", lookup_id, "secret")) + .map(|(_, v)| v.to_string()) + .collect(), + typ: Type::Individual, + description: config + .value((prefix.as_str(), "users", lookup_id, "description")) + .map(|v| v.to_string()), + quota: config + .property((prefix.as_str(), "users", lookup_id, "quota"))? + .unwrap_or(0), + member_of: config + .values((prefix.as_str(), "users", lookup_id, "member-of")) + .map(|(_, v)| v.to_string()) + .collect(), + }); + let mut emails = Vec::new(); + for (pos, (_, email)) in config + .values((prefix.as_str(), "users", lookup_id, "email")) + .enumerate() + { + directory + .emails_to_ids + .entry(email.to_string()) + .or_default() + .push(if pos > 0 { + EmailType::Alias(id) + } else { + EmailType::Primary(id) + }); + + emails.push(if pos > 0 { + EmailType::Alias(email.to_string()) + } else { + EmailType::Primary(email.to_string()) + }); + } + for (_, email) in config.values((prefix.as_str(), "users", lookup_id, "email-list")) { + directory + .emails_to_ids + .entry(email.to_string()) + .or_default() + .push(EmailType::List(id)); + emails.push(EmailType::List(email.to_string())); + } + directory.ids_to_email.insert(id, emails); + } + + for lookup_id in config.sub_keys((prefix.as_str(), "groups")) { + let id = directory.principals.len() as u32; + let name = config + .value_require((prefix.as_str(), "groups", lookup_id, "name"))? + .to_string(); + directory.names.insert(name.clone(), id); + directory.principals.push(Principal { + id, + name, + secrets: vec![], + typ: Type::Group, + description: config + .value((prefix.as_str(), "groups", lookup_id, "description")) + .map(|v| v.to_string()), + quota: config + .property((prefix.as_str(), "groups", lookup_id, "quota"))? + .unwrap_or(0), + member_of: config + .values((prefix.as_str(), "groups", lookup_id, "member-of")) + .map(|(_, v)| v.to_string()) + .collect(), + }); + } + + Ok(Arc::new(directory)) + } +} diff --git a/crates/directory/src/memory/lookup.rs b/crates/directory/src/memory/lookup.rs new file mode 100644 index 00000000..e56fc739 --- /dev/null +++ b/crates/directory/src/memory/lookup.rs @@ -0,0 +1,115 @@ +use mail_send::Credentials; + +use crate::{Directory, DirectoryError, Principal}; + +use super::{EmailType, MemoryDirectory}; + +#[async_trait::async_trait] +impl Directory for MemoryDirectory { + async fn authenticate( + &self, + credentials: &Credentials, + ) -> crate::Result> { + let (username, secret) = match credentials { + Credentials::Plain { username, secret } => (username, secret), + Credentials::OAuthBearer { token } => (token, token), + Credentials::XOauth2 { username, secret } => (username, secret), + }; + match self + .names + .get(username) + .and_then(|id| self.principals.get(*id as usize)) + { + Some(principal) if principal.verify_secret(secret) => Ok(Some(principal.clone())), + _ => Ok(None), + } + } + + async fn principal_by_name(&self, name: &str) -> crate::Result> { + Ok(self + .names + .get(name) + .and_then(|id| self.principals.get(*id as usize)) + .cloned()) + } + + async fn principal_by_id(&self, id: u32) -> crate::Result> { + Ok(self.principals.get(id as usize).cloned()) + } + + async fn member_of(&self, principal: &Principal) -> crate::Result> { + let mut result = Vec::with_capacity(principal.member_of.len()); + for member in &principal.member_of { + if let Some(id) = self.names.get(member) { + result.push(*id); + } + } + Ok(result) + } + + async fn emails_by_id(&self, id: u32) -> crate::Result> { + let mut result = Vec::new(); + if let Some(emails) = self.ids_to_email.get(&id) { + for email in emails { + match email { + EmailType::Primary(email) | EmailType::Alias(email) => { + result.push(email.clone()) + } + _ => {} + } + } + } + + Ok(result) + } + + async fn ids_by_email(&self, address: &str) -> crate::Result> { + Ok(self + .emails_to_ids + .get(address) + .map(|ids| { + ids.iter() + .map(|t| match t { + EmailType::Primary(id) | EmailType::Alias(id) | EmailType::List(id) => *id, + }) + .collect::>() + }) + .unwrap_or_default()) + } + + async fn rcpt(&self, address: &str) -> crate::Result { + Ok(self.emails_to_ids.get(address).is_some()) + } + + async fn vrfy(&self, address: &str) -> crate::Result> { + let mut result = Vec::new(); + for (key, value) in &self.emails_to_ids { + if key.contains(address) && value.iter().any(|t| matches!(t, EmailType::Primary(_))) { + result.push(key.clone()) + } + } + Ok(result) + } + + async fn expn(&self, address: &str) -> crate::Result> { + let mut result = Vec::new(); + for (key, value) in &self.emails_to_ids { + if key == address { + for item in value { + if let EmailType::List(id) = item { + for addr in self.ids_to_email.get(id).unwrap() { + if let EmailType::Primary(addr) = addr { + result.push(addr.clone()) + } + } + } + } + } + } + Ok(result) + } + + async fn query(&self, _query: &str, _params: &[&str]) -> crate::Result { + Err(DirectoryError::unsupported("memory", "query")) + } +} diff --git a/crates/directory/src/memory/mod.rs b/crates/directory/src/memory/mod.rs new file mode 100644 index 00000000..846c7a42 --- /dev/null +++ b/crates/directory/src/memory/mod.rs @@ -0,0 +1,20 @@ +use ahash::AHashMap; + +use crate::Principal; + +pub mod config; +pub mod lookup; + +#[derive(Default)] +pub struct MemoryDirectory { + principals: Vec, + names: AHashMap, + emails_to_ids: AHashMap>>, + ids_to_email: AHashMap>>, +} + +enum EmailType { + Primary(T), + Alias(T), + List(T), +} diff --git a/crates/directory/src/secret.rs b/crates/directory/src/secret.rs new file mode 100644 index 00000000..d9841e3d --- /dev/null +++ b/crates/directory/src/secret.rs @@ -0,0 +1,159 @@ +use argon2::Argon2; +use mail_builder::encoders::base64::base64_encode; +use mail_parser::decoders::base64::base64_decode; +use password_hash::PasswordHash; +use pbkdf2::Pbkdf2; +use pwhash::{bcrypt, bsdi_crypt, md5_crypt, sha1_crypt, sha256_crypt, sha512_crypt, unix_crypt}; +use scrypt::Scrypt; +use sha1::Digest; +use sha1::Sha1; +use sha2::Sha256; +use sha2::Sha512; + +use crate::Principal; + +impl Principal { + pub fn verify_secret(&self, secret: &str) -> bool { + self.secrets.iter().any(|s| verify_secret_hash(s, secret)) + } +} + +fn verify_secret_hash(hashed_secret: &str, secret: &str) -> bool { + if hashed_secret.starts_with('$') { + if hashed_secret.starts_with("$argon2") + || hashed_secret.starts_with("$pbkdf2") + || hashed_secret.starts_with("$scrypt") + { + match PasswordHash::new(hashed_secret) { + Ok(hash) => hash + .verify_password(&[&Argon2::default(), &Pbkdf2, &Scrypt], secret) + .is_ok(), + Err(_) => { + tracing::warn!( + context = "directory", + event = "error", + hash = hashed_secret, + "Invalid password hash" + ); + false + } + } + } else if hashed_secret.starts_with("$2") { + // Blowfish crypt + bcrypt::verify(secret, hashed_secret) + } else if hashed_secret.starts_with("$6$") { + // SHA-512 crypt + sha512_crypt::verify(secret, hashed_secret) + } else if hashed_secret.starts_with("$5$") { + // SHA-256 crypt + sha256_crypt::verify(secret, hashed_secret) + } else if hashed_secret.starts_with("$sha1") { + // SHA-1 crypt + sha1_crypt::verify(secret, hashed_secret) + } else if hashed_secret.starts_with("$1") { + // MD5 based hash + md5_crypt::verify(secret, hashed_secret) + } else { + // Unknown hash + tracing::warn!( + context = "directory", + event = "error", + hash = hashed_secret, + "Invalid password hash" + ); + false + } + } else if hashed_secret.starts_with('_') { + // Enhanced DES-based hash + bsdi_crypt::verify(secret, hashed_secret) + } else if let Some(hashed_secret) = hashed_secret.strip_prefix('{') { + if let Some((algo, hashed_secret)) = hashed_secret.split_once('}') { + match algo { + "SHA" => { + // SHA-1 + let mut hasher = Sha1::new(); + hasher.update(secret.as_bytes()); + String::from_utf8(base64_encode(&hasher.finalize()[..]).unwrap_or_default()) + .unwrap() + == hashed_secret + } + "SSHA" => { + // Salted SHA-1 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..20).unwrap_or_default(); + let salt = decoded.get(20..).unwrap_or_default(); + let mut hasher = Sha1::new(); + hasher.update(secret.as_bytes()); + hasher.update(salt); + &hasher.finalize()[..] == hash + } + "SHA256" => { + // Verify hash + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + String::from_utf8(base64_encode(&hasher.finalize()[..]).unwrap_or_default()) + .unwrap() + == hashed_secret + } + "SSHA256" => { + // Salted SHA-256 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..32).unwrap_or_default(); + let salt = decoded.get(32..).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + hasher.update(salt); + &hasher.finalize()[..] == hash + } + "SHA512" => { + // SHA-512 + let mut hasher = Sha512::new(); + hasher.update(secret.as_bytes()); + String::from_utf8(base64_encode(&hasher.finalize()[..]).unwrap_or_default()) + .unwrap() + == hashed_secret + } + "SSHA512" => { + // Salted SHA-512 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..64).unwrap_or_default(); + let salt = decoded.get(64..).unwrap_or_default(); + let mut hasher = Sha512::new(); + hasher.update(secret.as_bytes()); + hasher.update(salt); + &hasher.finalize()[..] == hash + } + "MD5" => { + // MD5 + let digest = md5::compute(secret.as_bytes()); + String::from_utf8(base64_encode(&digest[..]).unwrap_or_default()).unwrap() + == hashed_secret + } + "CRYPT" | "crypt" => { + // Unix crypt + unix_crypt::verify(secret, hashed_secret) + } + "PLAIN" | "plain" | "CLEAR" | "clear" => hashed_secret == secret, + _ => { + tracing::warn!( + context = "directory", + event = "error", + algorithm = algo, + "Unsupported password hash algorithm" + ); + false + } + } + } else { + tracing::warn!( + context = "directory", + event = "error", + hash = hashed_secret, + "Invalid password hash" + ); + false + } + } else { + hashed_secret == secret + } +} diff --git a/crates/directory/src/sql/config.rs b/crates/directory/src/sql/config.rs index 919bad28..75aabbbc 100644 --- a/crates/directory/src/sql/config.rs +++ b/crates/directory/src/sql/config.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use sqlx::any::AnyPoolOptions; +use sqlx::any::{install_default_drivers, AnyPoolOptions}; use utils::config::{utils::AsKey, Config}; use crate::Directory; @@ -14,7 +14,7 @@ impl SqlDirectory { ) -> utils::config::Result> { let prefix = prefix.as_key(); let address = config.value_require((&prefix, "address"))?; - + install_default_drivers(); let pool = AnyPoolOptions::new() .max_connections( config @@ -64,27 +64,27 @@ impl SqlDirectory { .unwrap_or_default() .to_string(), column_name: config - .value((&prefix, "column.name")) + .value((&prefix, "columns.name")) .unwrap_or_default() .to_string(), column_description: config - .value((&prefix, "column.description")) + .value((&prefix, "columns.description")) .unwrap_or_default() .to_string(), column_secret: config - .value((&prefix, "column.secret")) + .value((&prefix, "columns.secret")) .unwrap_or_default() .to_string(), column_id: config - .value((&prefix, "column.id")) + .value((&prefix, "columns.id")) .unwrap_or_default() .to_string(), column_quota: config - .value((&prefix, "column.quota")) + .value((&prefix, "columns.quota")) .unwrap_or_default() .to_string(), column_type: config - .value((&prefix, "column.type")) + .value((&prefix, "columns.type")) .unwrap_or_default() .to_string(), }; diff --git a/crates/directory/src/sql/lookup.rs b/crates/directory/src/sql/lookup.rs index 37c5b734..9191d5da 100644 --- a/crates/directory/src/sql/lookup.rs +++ b/crates/directory/src/sql/lookup.rs @@ -23,7 +23,7 @@ impl Directory for SqlDirectory { .await? { self.mappings.row_to_principal(row).map(|p| { - if p.secret.as_ref().map_or(false, |s| s == secret) { + if p.verify_secret(secret) { Some(p) } else { None @@ -85,7 +85,7 @@ impl Directory for SqlDirectory { } async fn rcpt(&self, address: &str) -> crate::Result { - sqlx::query_scalar::<_, i64>(&self.mappings.query_recipients) + sqlx::query(&self.mappings.query_recipients) .bind(address) .fetch_optional(&self.pool) .await @@ -136,7 +136,9 @@ impl SqlMappings { } else if name.eq_ignore_ascii_case(&self.column_name) { principal.name = row.try_get::, _>(idx)?.unwrap_or_default(); } else if name.eq_ignore_ascii_case(&self.column_secret) { - principal.secret = row.try_get::, _>(idx)?; + if let Some(secret) = row.try_get::, _>(idx)? { + principal.secrets.push(secret); + } } else if name.eq_ignore_ascii_case(&self.column_type) { if let Some(typ) = row.try_get::, _>(idx)? { match typ.as_str() { @@ -148,7 +150,7 @@ impl SqlMappings { } else if name.eq_ignore_ascii_case(&self.column_description) { principal.description = row.try_get::, _>(idx)?; } else if name.eq_ignore_ascii_case(&self.column_quota) { - principal.quota = row.try_get::(idx)? as u32; + principal.quota = row.try_get::(idx).unwrap_or_default() as u32; } } diff --git a/crates/directory/src/sql/mod.rs b/crates/directory/src/sql/mod.rs index 6cbbf424..6bb74e93 100644 --- a/crates/directory/src/sql/mod.rs +++ b/crates/directory/src/sql/mod.rs @@ -8,6 +8,7 @@ pub struct SqlDirectory { mappings: SqlMappings, } +#[derive(Debug)] pub(crate) struct SqlMappings { query_login: String, query_name: String, diff --git a/tests/resources/ldap.cfg b/tests/resources/ldap.cfg new file mode 100644 index 00000000..349022c9 --- /dev/null +++ b/tests/resources/ldap.cfg @@ -0,0 +1,116 @@ +################# +# LDAP test config + +################# +# General configuration. +debug = true +watchconfig = true + +################# +# Server configuration. +[ldap] + enabled = true + # run on a non privileged port + listen = "0.0.0.0:3893" + +[ldaps] +# to enable ldaps genrerate a certificate, eg. with: +# openssl req -x509 -newkey rsa:4096 -keyout example.key -out example.crt -days 365 -nodes -subj '/CN=`hostname`' + enabled = false + listen = "0.0.0.0:3894" + cert = "example.crt" + key = "example.key" + +################# +# The backend section controls the data store. +[backend] + datastore = "config" + baseDN = "dc=example,dc=org" + nameformat = "cn" + groupformat = "ou" + +[behaviors] + # Ignore all capabilities restrictions, for instance allowing every user to perform a search + IgnoreCapabilities = false + # Enable a "fail2ban" type backoff mechanism temporarily banning repeated failed login attempts + LimitFailedBinds = true + # How many failed login attempts are allowed before a ban is imposed + NumberOfFailedBinds = 3 + # How long (in seconds) is the window for failed login attempts + PeriodOfFailedBinds = 10 + # How long (in seconds) is the ban duration + BlockFailedBindsFor = 60 + # Clean learnt IP addresses every N seconds + PruneSourceTableEvery = 600 + # Clean learnt IP addresses not seen in N seconds + PruneSourcesOlderThan = 600 + +################# +# The users section contains a hardcoded list of valid users. +[[users]] + name = "john" + givenname = "john.doe@example.org" + sn = "info@example.org" + uidnumber = 2 + primarygroup = 5 + mail = "john@example.org" + [[users.customattributes]] + principalName = ["John Doe"] + userPassword = ["12345"] + +[[users]] + name = "jane" + sn = "info@example.org" + mail = "jane@example.org" + uidnumber = 3 + primarygroup = 5 + [[users.customattributes]] + otherGroups = ["support"] + principalName = ["Jane Doe"] + userPassword = ["abcde"] + +[[users]] + name = "bill" + sn = "info@example.org" + mail = "bill@example.org" + uidnumber = 4 + [[users.customattributes]] + principalName = ["Bill Foobar"] + diskQuota = [500000] + userPassword = ["$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe"] + +[[users]] + name = "serviceuser" + mail = "serviceuser@example.org" + uidnumber = 5003 + primarygroup = 5502 + passsha256 = "652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0" # mysecret + [[users.capabilities]] + action = "search" + object = "*" + + +################# +# The groups section contains a hardcoded list of valid users. +[[groups]] + name = "sales" + gidnumber = 5 + +[[groups]] + name = "support" + gidnumber = 6 + +[[groups]] + name = "svcaccts" + gidnumber = 5502 + + +################# +# Enable and configure the optional REST API here. +[api] + enabled = false + internals = true # debug application performance + tls = false # enable TLS for production!! + listen = "0.0.0.0:5555" + cert = "cert.pem" + key = "key.pem" diff --git a/tests/src/directory/imap.rs b/tests/src/directory/imap.rs index 10e67395..d00835ae 100644 --- a/tests/src/directory/imap.rs +++ b/tests/src/directory/imap.rs @@ -18,15 +18,15 @@ use super::dummy_tls_acceptor; #[tokio::test] async fn imap_directory() { // Enable logging - tracing::subscriber::set_global_default( + /*tracing::subscriber::set_global_default( tracing_subscriber::FmtSubscriber::builder() .with_max_level(tracing::Level::DEBUG) .finish(), ) - .unwrap(); + .unwrap();*/ // Obtain directory handle - let handle = parse_config().remove("imap").unwrap(); + let handle = parse_config().directories.remove("imap").unwrap(); // Spawn mock LMTP server let shutdown = spawn_mock_imap_server(5); diff --git a/tests/src/directory/ldap.rs b/tests/src/directory/ldap.rs index e69de29b..257d33e1 100644 --- a/tests/src/directory/ldap.rs +++ b/tests/src/directory/ldap.rs @@ -0,0 +1,195 @@ +use std::fmt::Debug; + +use directory::{Principal, Type}; +use mail_send::Credentials; + +use crate::directory::parse_config; + +#[tokio::test] +async fn ldap_directory() { + // Enable logging + tracing::subscriber::set_global_default( + tracing_subscriber::FmtSubscriber::builder() + .with_max_level(tracing::Level::DEBUG) + .finish(), + ) + .unwrap(); + + // Obtain directory handle + let handle = parse_config().directories.remove("ldap").unwrap(); + + // Test authentication + assert_eq!( + handle + .authenticate(&Credentials::Plain { + username: "john".to_string(), + secret: "12345".to_string() + }) + .await + .unwrap() + .unwrap(), + Principal { + id: 2, + name: "john".to_string(), + description: "John Doe".to_string().into(), + secrets: vec!["12345".to_string()], + typ: Type::Individual, + member_of: vec!["ou=sales,ou=groups,dc=example,dc=org".to_string()], + ..Default::default() + } + ); + assert_eq!( + handle + .authenticate(&Credentials::Plain { + username: "bill".to_string(), + secret: "password".to_string() + }) + .await + .unwrap() + .unwrap(), + Principal { + id: 4, + name: "bill".to_string(), + description: "Bill Foobar".to_string().into(), + secrets: vec![ + "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe".to_string() + ], + typ: Type::Individual, + quota: 500000, + ..Default::default() + } + ); + assert!(handle + .authenticate(&Credentials::Plain { + username: "bill".to_string(), + secret: "invalid".to_string() + }) + .await + .unwrap() + .is_none()); + + // Get by id + assert_eq!( + handle.principal_by_id(2).await.unwrap().unwrap(), + Principal { + id: 2, + name: "john".to_string(), + description: "John Doe".to_string().into(), + typ: Type::Individual, + secrets: vec!["12345".to_string()], + member_of: vec!["ou=sales,ou=groups,dc=example,dc=org".to_string()], + ..Default::default() + } + ); + + // Get user by name + let mut principal = handle.principal_by_name("jane").await.unwrap().unwrap(); + principal.member_of.sort_unstable(); + assert_eq!( + principal, + Principal { + id: 3, + name: "jane".to_string(), + description: "Jane Doe".to_string().into(), + typ: Type::Individual, + secrets: vec!["abcde".to_string()], + member_of: vec![ + "ou=sales,ou=groups,dc=example,dc=org".to_string(), + "support".to_string() + ], + ..Default::default() + } + ); + + // Get group by name + assert_eq!( + handle.principal_by_name("sales").await.unwrap().unwrap(), + Principal { + id: 5, + name: "sales".to_string(), + description: "sales".to_string().into(), + typ: Type::Group, + ..Default::default() + } + ); + + // Member of + compare_sorted( + handle + .member_of(&handle.principal_by_name("john").await.unwrap().unwrap()) + .await + .unwrap(), + vec![5], + ); + compare_sorted( + handle + .member_of(&handle.principal_by_name("jane").await.unwrap().unwrap()) + .await + .unwrap(), + vec![5, 6], + ); + + // Emails by id + compare_sorted( + handle.emails_by_id(2).await.unwrap(), + vec![ + "john@example.org".to_string(), + "john.doe@example.org".to_string(), + ], + ); + compare_sorted( + handle.emails_by_id(4).await.unwrap(), + vec!["bill@example.org".to_string()], + ); + + // Ids by email + compare_sorted( + handle.ids_by_email("jane@example.org").await.unwrap(), + vec![3], + ); + compare_sorted( + handle.ids_by_email("info@example.org").await.unwrap(), + vec![2, 3, 4], + ); + + // RCPT TO + assert!(handle.rcpt("jane@example.org").await.unwrap()); + assert!(handle.rcpt("info@example.org").await.unwrap()); + assert!(!handle.rcpt("invalid@example.org").await.unwrap()); + + // VRFY + compare_sorted( + handle.vrfy("jane").await.unwrap(), + vec!["jane@example.org".to_string()], + ); + compare_sorted( + handle.vrfy("john").await.unwrap(), + vec!["john@example.org".to_string()], + ); + compare_sorted(handle.vrfy("info").await.unwrap(), Vec::::new()); + compare_sorted(handle.vrfy("invalid").await.unwrap(), Vec::::new()); + + // EXPN + compare_sorted( + handle.expn("info@example.org").await.unwrap(), + vec![ + "bill@example.org".to_string(), + "jane@example.org".to_string(), + "john@example.org".to_string(), + ], + ); + compare_sorted( + handle.expn("john@example.org").await.unwrap(), + Vec::::new(), + ); +} + +fn compare_sorted(v1: Vec, v2: Vec) { + for val in v1.iter() { + assert!(v2.contains(val), "{v1:?} != {v2:?}"); + } + + for val in v2.iter() { + assert!(v1.contains(val), "{v1:?} != {v2:?}"); + } +} diff --git a/tests/src/directory/mod.rs b/tests/src/directory/mod.rs index 8accc88e..8d1e769c 100644 --- a/tests/src/directory/mod.rs +++ b/tests/src/directory/mod.rs @@ -3,8 +3,7 @@ pub mod ldap; pub mod smtp; pub mod sql; -use ahash::AHashMap; -use directory::{config::ConfigDirectory, Directory}; +use directory::{config::ConfigDirectory, DirectoryConfig}; use mail_send::Credentials; use rustls::{Certificate, PrivateKey, ServerConfig}; use rustls_pemfile::{certs, pkcs8_private_keys}; @@ -16,15 +15,18 @@ const CONFIG: &str = r#" protocol = "sql" address = "sqlite::memory:" +[directory."sql".pool] +max-connections = 1 + [directory."sql".query] -login = "SELECT id, secret, description, quota FROM accounts WHERE name = ? AND active = true AND type = 'individual'" -name = "SELECT id, type, description, quota FROM accounts WHERE name = ?" -id = "SELECT name, type, description, quota FROM accounts WHERE id = ?" +login = "SELECT id, name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true AND type = 'individual'" +name = "SELECT id, name, type, description, quota FROM accounts WHERE name = ?" +id = "SELECT id, name, type, description, quota FROM accounts WHERE id = ?" members = "SELECT gid FROM group_members WHERE uid = ?" recipients = "SELECT id FROM emails WHERE address = ?" -emails = "SELECT address FROM emails WHERE id = ? AND type != 'list' ORDER BY type DESC" -verify = "SELECT address FROM emails WHERE address LIKE '%' || ? || '%' AND type != 'list' LIMIT 5" -expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.id = l.id WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' LIMIT 50" +emails = "SELECT address FROM emails WHERE id = ? AND type != 'list' ORDER BY type DESC, address ASC" +verify = "SELECT address FROM emails WHERE address LIKE '%' || ? || '%' AND type = 'primary' ORDER BY address LIMIT 5" +expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.id = l.id WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50" [directory."sql".columns] name = "name" @@ -35,35 +37,41 @@ email = "address" quota = "quota" type = "type" +[directory."sql".lookup] +domains = "SELECT name FROM domains WHERE name = ?" + [directory."ldap"] protocol = "ldap" address = "ldap://localhost:3893" -base-dn = "dc=example,dc=com" +base-dn = "dc=example,dc=org" [directory."ldap".bind] -dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=com" +dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org" secret = "mysecret" [directory."ldap".filter] login = "(&(objectClass=posixAccount)(accountStatus=active)(cn=?))" -name = "(&(!(objectClass=posixAccount)(objectClass=posixGroup))(cn=?))" -email = "(&(!(objectClass=posixAccount)(objectClass=posixGroup))(!(mail=?)(mailAliases=?)(mailLists=?)))" +name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(cn=?))" +email = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(givenName=?)(sn=?)))" id = "(|(&(objectClass=posixAccount)(uidNumber=?))(&(objectClass=posixGroup)(gidNumber=?)))" -verify = "(&(!(objectClass=posixAccount)(objectClass=posixGroup))(!(mail=*?*)(mailAliases=*?*)))" -expand = "(&(!(objectClass=posixAccount)(objectClass=posixGroup))(mailLists=?))" +verify = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=*?*)(givenName=*?*)))" +expand = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(sn=?))" [directory."ldap".object-classes] user = "posixAccount" group = "posixGroup" +# Glauth does not support searchable custom attributes so +# 'sn' and 'givenName' are used to search for aliases/lists. + [directory."ldap".attributes] name = "cn" -description = "description" +description = ["principalName", "description"] secret = "userPassword" -groups = "memberOf" +groups = ["memberOf", "otherGroups"] id = ["uidNumber", "gidNumber"] email = "mail" -email-alias = "mailAliases" +email-alias = "givenName" quota = "diskQuota" [directory."imap"] @@ -94,9 +102,47 @@ max-connections = 5 implicit = true allow-invalid-certs = true +[directory."local"] +protocol = "memory" + +[[directory."local".users]] +name = "john" +description = "John Doe" +secret = "12345" +email = ["john@example.org", "jdoe@example.org", "john.doe@example.org"] +email-list = ["info@example.org"] +member-of = ["sales"] + +[[directory."local".users]] +name = "jane" +description = "Jane Doe" +secret = "abcde" +email = "jane@example.org" +email-list = ["info@example.org"] +member-of = ["sales", "support"] + +[[directory."local".users]] +name = "bill" +description = "Bill Foobar" +secret = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe" +quota = 500000 +email = "bill@example.org" +email-list = ["info@example.org"] + +[[directory."local".groups]] +name = "sales" +description = "Sales Team" + +[[directory."local".groups]] +name = "support" +description = "Support Team" + +[directory."local".lookup] +domains = ["example.org"] + "#; -pub fn parse_config() -> AHashMap> { +pub fn parse_config() -> DirectoryConfig { utils::config::Config::parse(CONFIG) .unwrap() .parse_directory() diff --git a/tests/src/directory/smtp.rs b/tests/src/directory/smtp.rs index b6d3a0dd..4866e13f 100644 --- a/tests/src/directory/smtp.rs +++ b/tests/src/directory/smtp.rs @@ -22,7 +22,7 @@ async fn smtp_directory() { let shutdown = spawn_mock_lmtp_server(5); // Obtain directory handle - let handle = parse_config().remove("smtp").unwrap(); + let handle = parse_config().directories.remove("smtp").unwrap(); // Basic lookup let tests = vec![ diff --git a/tests/src/directory/sql.rs b/tests/src/directory/sql.rs index 34b4b801..1e0fedff 100644 --- a/tests/src/directory/sql.rs +++ b/tests/src/directory/sql.rs @@ -1,12 +1,221 @@ -use directory::Directory; +use directory::{Directory, Principal, Type}; use jmap_proto::types::id::Id; +use mail_send::Credentials; + +use crate::directory::parse_config; + +#[tokio::test] +async fn sql_directory() { + // Enable logging + /*tracing::subscriber::set_global_default( + tracing_subscriber::FmtSubscriber::builder() + .with_max_level(tracing::Level::DEBUG) + .finish(), + ) + .unwrap();*/ + + // Obtain directory handle + let handle = parse_config().directories.remove("sql").unwrap(); + + // Create tables + create_test_directory(handle.as_ref()).await; + + // Create test users + create_test_user(handle.as_ref(), "john", "12345", "John Doe").await; + create_test_user(handle.as_ref(), "jane", "abcde", "Jane Doe").await; + create_test_user( + handle.as_ref(), + "bill", + "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe", + "Bill Foobar", + ) + .await; + set_test_quota(handle.as_ref(), "bill", 500000).await; + + // Create test groups + create_test_group(handle.as_ref(), "sales", "Sales Team").await; + create_test_group(handle.as_ref(), "support", "Support Team").await; + + // Link users to groups + add_to_group(handle.as_ref(), "john", "sales").await; + add_to_group(handle.as_ref(), "jane", "sales").await; + add_to_group(handle.as_ref(), "jane", "support").await; + + // Add email addresses + link_test_address(handle.as_ref(), "john", "john@example.org", "primary").await; + link_test_address(handle.as_ref(), "jane", "jane@example.org", "primary").await; + link_test_address(handle.as_ref(), "bill", "bill@example.org", "primary").await; + + // Add aliases and lists + link_test_address(handle.as_ref(), "john", "john.doe@example.org", "alias").await; + link_test_address(handle.as_ref(), "john", "jdoe@example.org", "alias").await; + link_test_address(handle.as_ref(), "john", "info@example.org", "list").await; + link_test_address(handle.as_ref(), "jane", "info@example.org", "list").await; + link_test_address(handle.as_ref(), "bill", "info@example.org", "list").await; + + // Test authentication + assert_eq!( + handle + .authenticate(&Credentials::Plain { + username: "john".to_string(), + secret: "12345".to_string() + }) + .await + .unwrap() + .unwrap(), + Principal { + id: 2, + name: "john".to_string(), + description: "John Doe".to_string().into(), + secrets: vec!["12345".to_string()], + typ: Type::Individual, + ..Default::default() + } + ); + assert_eq!( + handle + .authenticate(&Credentials::Plain { + username: "bill".to_string(), + secret: "password".to_string() + }) + .await + .unwrap() + .unwrap(), + Principal { + id: 4, + name: "bill".to_string(), + description: "Bill Foobar".to_string().into(), + secrets: vec![ + "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe".to_string() + ], + typ: Type::Individual, + quota: 500000, + ..Default::default() + } + ); + assert!(handle + .authenticate(&Credentials::Plain { + username: "bill".to_string(), + secret: "invalid".to_string() + }) + .await + .unwrap() + .is_none()); + + // Get by id + assert_eq!( + handle.principal_by_id(2).await.unwrap().unwrap(), + Principal { + id: 2, + name: "john".to_string(), + description: "John Doe".to_string().into(), + typ: Type::Individual, + ..Default::default() + } + ); + + // Get user by name + assert_eq!( + handle.principal_by_name("jane").await.unwrap().unwrap(), + Principal { + id: 3, + name: "jane".to_string(), + description: "Jane Doe".to_string().into(), + typ: Type::Individual, + ..Default::default() + } + ); + + // Get group by name + assert_eq!( + handle.principal_by_name("sales").await.unwrap().unwrap(), + Principal { + id: 5, + name: "sales".to_string(), + description: "Sales Team".to_string().into(), + typ: Type::Group, + ..Default::default() + } + ); + + // Member of + assert_eq!( + handle + .member_of(&handle.principal_by_name("john").await.unwrap().unwrap()) + .await + .unwrap(), + vec![5] + ); + assert_eq!( + handle + .member_of(&handle.principal_by_name("jane").await.unwrap().unwrap()) + .await + .unwrap(), + vec![5, 6] + ); + + // Emails by id + assert_eq!( + handle.emails_by_id(2).await.unwrap(), + vec![ + "john@example.org".to_string(), + "jdoe@example.org".to_string(), + "john.doe@example.org".to_string(), + ] + ); + assert_eq!( + handle.emails_by_id(4).await.unwrap(), + vec!["bill@example.org".to_string(),] + ); + + // Ids by email + assert_eq!( + handle.ids_by_email("jane@example.org").await.unwrap(), + vec![3] + ); + assert_eq!( + handle.ids_by_email("info@example.org").await.unwrap(), + vec![2, 3, 4] + ); + + // RCPT TO + assert!(handle.rcpt("jane@example.org").await.unwrap()); + assert!(handle.rcpt("info@example.org").await.unwrap()); + assert!(!handle.rcpt("invalid@example.org").await.unwrap()); + + // VRFY + assert_eq!( + handle.vrfy("jane").await.unwrap(), + vec!["jane@example.org".to_string()] + ); + assert_eq!( + handle.vrfy("john").await.unwrap(), + vec!["john@example.org".to_string()] + ); + assert_eq!(handle.vrfy("info").await.unwrap(), Vec::::new()); + assert_eq!(handle.vrfy("invalid").await.unwrap(), Vec::::new()); + + // EXPN + assert_eq!( + handle.expn("info@example.org").await.unwrap(), + vec![ + "bill@example.org".to_string(), + "jane@example.org".to_string(), + "john@example.org".to_string() + ] + ); + assert_eq!( + handle.expn("john@example.org").await.unwrap(), + Vec::::new() + ); +} pub async fn create_test_directory(handle: &dyn Directory) { // Create tables for query in [ - "CREATE TABLE accounts (name TEXT, id INTEGER PRIMARY KEY, secret TEXT, description TEXT, type TEXT NOT NULL, quota INTEGER, active BOOLEAN DEFAULT 1)", + "CREATE TABLE accounts (name TEXT, id INTEGER PRIMARY KEY, secret TEXT, description TEXT, type TEXT NOT NULL, quota INTEGER DEFAULT 0, active BOOLEAN DEFAULT 1)", "CREATE TABLE group_members (uid INTEGER, gid INTEGER, PRIMARY KEY (uid, gid))", - "CREATE TABLE emails (id INTEGER NOT NULL, email TEXT NOT NULL, type TEXT, PRIMARY KEY (id, email))", + "CREATE TABLE emails (id INTEGER NOT NULL, address TEXT NOT NULL, type TEXT, PRIMARY KEY (id, address))", "INSERT INTO accounts (name, secret, type) VALUES ('admin', 'secret', 'individual')", ] { handle.query(query, &[]).await.unwrap_or_else(|_| panic!("failed for {query}")); @@ -16,13 +225,13 @@ pub async fn create_test_directory(handle: &dyn Directory) { pub async fn create_test_user(handle: &dyn Directory, login: &str, secret: &str, name: &str) -> Id { handle .query( - "INSERT OR IGNORE INTO users (name, secret, description, type, is_active) VALUES (?, ?, ?, 'individual', true)", + "INSERT OR IGNORE INTO accounts (name, secret, description, type, active) VALUES (?, ?, ?, 'individual', true)", &[login, secret, name], ) .await .unwrap(); - Id::from(handle.principal_by_name(login).await.unwrap().unwrap().id) + Id::from(get_principal_id(handle, login).await) } pub async fn create_test_user_with_email( @@ -39,34 +248,27 @@ pub async fn create_test_user_with_email( pub async fn create_test_group(handle: &dyn Directory, login: &str, name: &str) -> Id { handle .query( - "INSERT OR IGNORE INTO users (name, description, type, is_active) VALUES (?, ?, 'group', true)", + "INSERT OR IGNORE INTO accounts (name, description, type, active) VALUES (?, ?, 'group', true)", &[login, name], ) .await .unwrap(); - let id = handle.principal_by_name(login).await.unwrap().unwrap().id; + Id::from(get_principal_id(handle, login).await) +} - handle - .query( - &format!( - "INSERT OR IGNORE INTO emails (id, email, type) VALUES ({}, ?, 'primary')", - id - ), - &[login], - ) - .await - .unwrap(); - - Id::from(id) +pub async fn create_test_group_with_email(handle: &dyn Directory, login: &str, name: &str) -> Id { + let id = create_test_group(handle, login, name).await; + link_test_address(handle, login, login, "primary").await; + id } pub async fn link_test_address(handle: &dyn Directory, login: &str, address: &str, typ: &str) { - let id = handle.principal_by_name(login).await.unwrap().unwrap().id; + let id = get_principal_id(handle, login).await; handle .query( &format!( - "INSERT OR IGNORE INTO emails (id, email, type) VALUES ({}, ?, ?)", + "INSERT OR IGNORE INTO emails (id, address, type) VALUES ({}, ?, ?)", id, ), &[address, typ], @@ -75,7 +277,28 @@ pub async fn link_test_address(handle: &dyn Directory, login: &str, address: &st .unwrap(); } -pub async fn add_to_group(handle: &dyn Directory, uid: u32, gid: u32) { +pub async fn set_test_quota(handle: &dyn Directory, login: &str, quota: u32) { + let id = get_principal_id(handle, login).await; + handle + .query( + &format!("UPDATE accounts SET quota = {} where id = {}", quota, id,), + &[], + ) + .await + .unwrap(); +} + +pub async fn add_to_group(handle: &dyn Directory, login: &str, group: &str) { + let user = handle.principal_by_name(login).await.unwrap().unwrap(); + let group = handle.principal_by_name(group).await.unwrap().unwrap(); + + let uid = user.id; + let gid = group.id; + + assert_ne!(uid, gid, "{user:?} {group:?}"); + assert_ne!(uid, u32::MAX, "{user:?} {group:?}"); + assert_ne!(gid, u32::MAX, "{user:?} {group:?}"); + handle .query( &format!( @@ -99,12 +322,18 @@ pub async fn remove_from_group(handle: &dyn Directory, uid: u32, gid: u32) { } pub async fn remove_test_alias(handle: &dyn Directory, login: &str, alias: &str) { - let id = handle.principal_by_name(login).await.unwrap().unwrap().id; + let id = get_principal_id(handle, login).await; handle .query( - &format!("DELETE FROM emails WHERE id = {} AND email = ?", id), + &format!("DELETE FROM emails WHERE id = {} AND address = ?", id), &[alias], ) .await .unwrap(); } + +async fn get_principal_id(handle: &dyn Directory, name: &str) -> u32 { + let p = handle.principal_by_name(name).await.unwrap().unwrap(); + assert_ne!(p.id, u32::MAX, "{name} {p:#?}"); + p.id +}