use crate::http::security::user::User;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
type MockUserEntry = (String, HashMap<String, Vec<String>>, Vec<String>);
#[derive(Debug, Clone)]
pub struct LdapConfig {
pub url: String,
pub base_dn: String,
pub user_search_base: String,
pub user_search_filter: String,
pub group_search_base: String,
pub group_search_filter: String,
pub group_role_attribute: String,
pub bind_dn: Option<String>,
pub bind_password: Option<String>,
pub user_dn_pattern: Option<String>,
pub connect_timeout: Duration,
pub operation_timeout: Duration,
pub use_starttls: bool,
pub role_prefix: String,
pub convert_to_uppercase: bool,
pub username_attribute: String,
pub email_attribute: String,
pub display_name_attribute: String,
pub attribute_mappings: HashMap<String, String>,
}
impl Default for LdapConfig {
fn default() -> Self {
Self {
url: "ldap://localhost:389".to_string(),
base_dn: String::new(),
user_search_base: "ou=users".to_string(),
user_search_filter: "(uid={0})".to_string(),
group_search_base: "ou=groups".to_string(),
group_search_filter: "(member={0})".to_string(),
group_role_attribute: "cn".to_string(),
bind_dn: None,
bind_password: None,
user_dn_pattern: None,
connect_timeout: Duration::from_secs(5),
operation_timeout: Duration::from_secs(10),
use_starttls: false,
role_prefix: "ROLE_".to_string(),
convert_to_uppercase: true,
username_attribute: "uid".to_string(),
email_attribute: "mail".to_string(),
display_name_attribute: "cn".to_string(),
attribute_mappings: HashMap::new(),
}
}
}
impl LdapConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
..Default::default()
}
}
pub fn active_directory(url: impl Into<String>, domain: impl Into<String>) -> Self {
let domain = domain.into();
let base_dn = domain
.split('.')
.map(|part| format!("dc={}", part))
.collect::<Vec<_>>()
.join(",");
Self {
url: url.into(),
base_dn,
user_search_filter: "(sAMAccountName={0})".to_string(),
group_search_filter: "(member:1.2.840.113556.1.4.1941:={0})".to_string(),
username_attribute: "sAMAccountName".to_string(),
display_name_attribute: "displayName".to_string(),
..Default::default()
}
}
pub fn base_dn(mut self, dn: impl Into<String>) -> Self {
self.base_dn = dn.into();
self
}
pub fn user_search_base(mut self, base: impl Into<String>) -> Self {
self.user_search_base = base.into();
self
}
pub fn user_search_filter(mut self, filter: impl Into<String>) -> Self {
self.user_search_filter = filter.into();
self
}
pub fn group_search_base(mut self, base: impl Into<String>) -> Self {
self.group_search_base = base.into();
self
}
pub fn group_search_filter(mut self, filter: impl Into<String>) -> Self {
self.group_search_filter = filter.into();
self
}
pub fn bind_dn(mut self, dn: impl Into<String>) -> Self {
self.bind_dn = Some(dn.into());
self
}
pub fn bind_password(mut self, password: impl Into<String>) -> Self {
self.bind_password = Some(password.into());
self
}
pub fn user_dn_pattern(mut self, pattern: impl Into<String>) -> Self {
self.user_dn_pattern = Some(pattern.into());
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub fn operation_timeout(mut self, timeout: Duration) -> Self {
self.operation_timeout = timeout;
self
}
pub fn use_starttls(mut self, use_tls: bool) -> Self {
self.use_starttls = use_tls;
self
}
pub fn role_prefix(mut self, prefix: impl Into<String>) -> Self {
self.role_prefix = prefix.into();
self
}
pub fn convert_to_uppercase(mut self, convert: bool) -> Self {
self.convert_to_uppercase = convert;
self
}
pub fn map_attribute(
mut self,
ldap_attr: impl Into<String>,
user_attr: impl Into<String>,
) -> Self {
self.attribute_mappings
.insert(ldap_attr.into(), user_attr.into());
self
}
pub fn full_user_search_base(&self) -> String {
if self.user_search_base.is_empty() {
self.base_dn.clone()
} else {
format!("{},{}", self.user_search_base, self.base_dn)
}
}
pub fn full_group_search_base(&self) -> String {
if self.group_search_base.is_empty() {
self.base_dn.clone()
} else {
format!("{},{}", self.group_search_base, self.base_dn)
}
}
pub fn build_user_filter(&self, username: &str) -> String {
self.user_search_filter.replace("{0}", username)
}
pub fn build_group_filter(&self, user_dn: &str) -> String {
self.group_search_filter.replace("{0}", user_dn)
}
pub fn build_user_dn(&self, username: &str) -> Option<String> {
self.user_dn_pattern
.as_ref()
.map(|pattern| pattern.replace("{0}", username))
}
}
#[derive(Debug, Clone)]
pub struct LdapAuthResult {
pub success: bool,
pub user_dn: Option<String>,
pub attributes: HashMap<String, Vec<String>>,
pub groups: Vec<String>,
pub error: Option<String>,
}
impl LdapAuthResult {
pub fn success(user_dn: String, attributes: HashMap<String, Vec<String>>) -> Self {
Self {
success: true,
user_dn: Some(user_dn),
attributes,
groups: Vec::new(),
error: None,
}
}
pub fn failure(error: impl Into<String>) -> Self {
Self {
success: false,
user_dn: None,
attributes: HashMap::new(),
groups: Vec::new(),
error: Some(error.into()),
}
}
pub fn with_groups(mut self, groups: Vec<String>) -> Self {
self.groups = groups;
self
}
pub fn get_attribute(&self, name: &str) -> Option<&str> {
self.attributes
.get(name)
.and_then(|values| values.first())
.map(|s| s.as_str())
}
pub fn get_attribute_values(&self, name: &str) -> Option<&Vec<String>> {
self.attributes.get(name)
}
}
#[derive(Debug, Clone)]
pub enum LdapError {
ConnectionFailed(String),
BindFailed(String),
UserNotFound(String),
AuthenticationFailed(String),
SearchFailed(String),
ConfigurationError(String),
Timeout,
TlsError(String),
}
impl std::fmt::Display for LdapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LdapError::ConnectionFailed(msg) => write!(f, "LDAP connection failed: {}", msg),
LdapError::BindFailed(msg) => write!(f, "LDAP bind failed: {}", msg),
LdapError::UserNotFound(msg) => write!(f, "User not found: {}", msg),
LdapError::AuthenticationFailed(msg) => write!(f, "Authentication failed: {}", msg),
LdapError::SearchFailed(msg) => write!(f, "LDAP search failed: {}", msg),
LdapError::ConfigurationError(msg) => write!(f, "Configuration error: {}", msg),
LdapError::Timeout => write!(f, "LDAP operation timed out"),
LdapError::TlsError(msg) => write!(f, "TLS error: {}", msg),
}
}
}
impl std::error::Error for LdapError {}
#[cfg_attr(feature = "ldap", async_trait::async_trait)]
pub trait LdapOperations: Send + Sync {
async fn connect(&self) -> Result<(), LdapError>;
async fn bind(&self, dn: &str, password: &str) -> Result<(), LdapError>;
async fn search(
&self,
base: &str,
filter: &str,
attrs: &[&str],
) -> Result<Vec<LdapAuthResult>, LdapError>;
async fn authenticate(
&self,
username: &str,
password: &str,
) -> Result<LdapAuthResult, LdapError>;
}
#[derive(Clone)]
pub struct LdapAuthenticator {
config: Arc<LdapConfig>,
#[cfg(feature = "ldap")]
client: Arc<dyn LdapOperations>,
}
impl LdapAuthenticator {
#[cfg(feature = "ldap")]
pub fn new<C: LdapOperations + 'static>(config: LdapConfig, client: C) -> Self {
Self {
config: Arc::new(config),
client: Arc::new(client),
}
}
pub fn with_config(config: LdapConfig) -> Self {
Self {
config: Arc::new(config),
#[cfg(feature = "ldap")]
client: Arc::new(MockLdapClient::new()),
}
}
pub fn config(&self) -> &LdapConfig {
&self.config
}
#[cfg(feature = "ldap")]
pub async fn authenticate(&self, username: &str, password: &str) -> Result<User, LdapError> {
let result = self.client.authenticate(username, password).await?;
if !result.success {
return Err(LdapError::AuthenticationFailed(
result.error.unwrap_or_else(|| "Unknown error".to_string()),
));
}
let user = self.build_user_from_result(username, &result);
Ok(user)
}
fn build_user_from_result(&self, username: &str, result: &LdapAuthResult) -> User {
let roles: Vec<String> = result
.groups
.iter()
.filter_map(|group_dn| {
group_dn
.split(',')
.next()
.and_then(|cn_part| cn_part.strip_prefix("cn=").or(cn_part.strip_prefix("CN=")))
.map(|cn| {
let role = if self.config.convert_to_uppercase {
cn.to_uppercase()
} else {
cn.to_string()
};
format!("{}{}", self.config.role_prefix, role)
})
})
.collect();
let _display_name = result
.get_attribute(&self.config.display_name_attribute)
.unwrap_or(username);
let mut user = User::new(username.to_string(), String::new());
if !roles.is_empty() {
user = user.roles(&roles);
}
if let Some(email) = result.get_attribute(&self.config.email_attribute) {
user = user.authorities(&[format!("email:{}", email)]);
}
if let Some(ref dn) = result.user_dn {
user = user.authorities(&[format!("dn:{}", dn)]);
}
user
}
}
#[derive(Default)]
pub struct MockLdapClient {
users: std::sync::RwLock<HashMap<String, MockUserEntry>>,
}
impl MockLdapClient {
pub fn new() -> Self {
Self::default()
}
pub fn add_user(
&self,
username: &str,
password: &str,
attributes: HashMap<String, Vec<String>>,
groups: Vec<String>,
) {
let mut users = self.users.write().unwrap();
users.insert(
username.to_string(),
(password.to_string(), attributes, groups),
);
}
}
#[cfg_attr(feature = "ldap", async_trait::async_trait)]
impl LdapOperations for MockLdapClient {
async fn connect(&self) -> Result<(), LdapError> {
Ok(())
}
async fn bind(&self, _dn: &str, _password: &str) -> Result<(), LdapError> {
Ok(())
}
async fn search(
&self,
_base: &str,
_filter: &str,
_attrs: &[&str],
) -> Result<Vec<LdapAuthResult>, LdapError> {
Ok(Vec::new())
}
async fn authenticate(
&self,
username: &str,
password: &str,
) -> Result<LdapAuthResult, LdapError> {
let users = self.users.read().unwrap();
match users.get(username) {
Some((stored_password, attributes, groups)) if stored_password == password => {
Ok(LdapAuthResult::success(
format!("uid={},ou=users,dc=example,dc=com", username),
attributes.clone(),
)
.with_groups(groups.clone()))
}
Some(_) => Err(LdapError::AuthenticationFailed(
"Invalid password".to_string(),
)),
None => Err(LdapError::UserNotFound(username.to_string())),
}
}
}
pub trait LdapContextMapper: Send + Sync {
fn map_user(&self, username: &str, result: &LdapAuthResult, config: &LdapConfig) -> User;
}
#[derive(Default)]
pub struct DefaultLdapContextMapper;
impl LdapContextMapper for DefaultLdapContextMapper {
fn map_user(&self, username: &str, result: &LdapAuthResult, config: &LdapConfig) -> User {
let roles: Vec<String> = result
.groups
.iter()
.filter_map(|group_dn| {
group_dn
.split(',')
.next()
.and_then(|cn_part| cn_part.strip_prefix("cn=").or(cn_part.strip_prefix("CN=")))
.map(|cn| {
let role = if config.convert_to_uppercase {
cn.to_uppercase()
} else {
cn.to_string()
};
format!("{}{}", config.role_prefix, role)
})
})
.collect();
User::new(username.to_string(), String::new()).roles(&roles)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ldap_config_builder() {
let config = LdapConfig::new("ldap://localhost:389")
.base_dn("dc=example,dc=com")
.user_search_filter("(uid={0})")
.bind_dn("cn=admin,dc=example,dc=com")
.bind_password("secret");
assert_eq!(config.url, "ldap://localhost:389");
assert_eq!(config.base_dn, "dc=example,dc=com");
assert_eq!(
config.bind_dn,
Some("cn=admin,dc=example,dc=com".to_string())
);
}
#[test]
fn test_active_directory_config() {
let config = LdapConfig::active_directory("ldap://dc.example.com", "example.com");
assert_eq!(config.base_dn, "dc=example,dc=com");
assert_eq!(config.user_search_filter, "(sAMAccountName={0})");
assert_eq!(config.username_attribute, "sAMAccountName");
}
#[test]
fn test_build_user_filter() {
let config = LdapConfig::new("ldap://localhost").user_search_filter("(uid={0})");
assert_eq!(config.build_user_filter("john"), "(uid=john)");
}
#[test]
fn test_build_user_dn() {
let config = LdapConfig::new("ldap://localhost")
.user_dn_pattern("uid={0},ou=users,dc=example,dc=com");
assert_eq!(
config.build_user_dn("john"),
Some("uid=john,ou=users,dc=example,dc=com".to_string())
);
}
#[test]
fn test_ldap_auth_result() {
let mut attrs = HashMap::new();
attrs.insert("cn".to_string(), vec!["John Doe".to_string()]);
attrs.insert("mail".to_string(), vec!["john@example.com".to_string()]);
let result = LdapAuthResult::success("uid=john,dc=example,dc=com".to_string(), attrs)
.with_groups(vec!["cn=admins,ou=groups,dc=example,dc=com".to_string()]);
assert!(result.success);
assert_eq!(result.get_attribute("cn"), Some("John Doe"));
assert_eq!(result.get_attribute("mail"), Some("john@example.com"));
assert_eq!(result.groups.len(), 1);
}
#[tokio::test]
async fn test_mock_ldap_client() {
let client = MockLdapClient::new();
let mut attrs = HashMap::new();
attrs.insert("cn".to_string(), vec!["Test User".to_string()]);
client.add_user(
"testuser",
"password123",
attrs,
vec!["cn=users,ou=groups,dc=example,dc=com".to_string()],
);
let result = client.authenticate("testuser", "password123").await;
assert!(result.is_ok());
let result = client.authenticate("testuser", "wrongpass").await;
assert!(result.is_err());
let result = client.authenticate("unknown", "password").await;
assert!(result.is_err());
}
#[test]
fn test_ldap_error_display() {
let err = LdapError::ConnectionFailed("Connection refused".to_string());
assert!(err.to_string().contains("Connection refused"));
let err = LdapError::UserNotFound("john".to_string());
assert!(err.to_string().contains("john"));
}
}