Implement LDAP Provider Phase 2.1: DataProvider trait with OpenLDAP/AD support
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled

This commit is contained in:
Warren
2026-06-22 03:34:17 +08:00
parent 4ab282bbff
commit 912bc21929
6 changed files with 518 additions and 0 deletions

View File

@@ -0,0 +1,374 @@
//! LDAP Authentication Provider
//!
//! Implements DataProvider trait for LDAP/Active Directory authentication.
//! Compatible with OpenLDAP and Microsoft Active Directory.
//! Uses tokio::spawn_blocking to wrap async LDAP operations.
use std::path::PathBuf;
use tracing::{info, warn, debug};
use super::{User, ProviderError, DataProvider};
/// LDAP Provider Configuration
#[derive(Debug, Clone)]
pub struct LdapConfig {
/// LDAP server URL (e.g., ldap://server:389 or ldaps://server:636)
pub ldap_url: String,
/// Base DN for user searches (e.g., dc=example,dc=com)
pub base_dn: String,
/// Bind DN for authenticated searches (e.g., cn=admin,dc=example,dc=com)
pub bind_dn: String,
/// Bind password for authenticated searches
pub bind_password: String,
/// User search base (e.g., ou=users,dc=example,dc=com)
pub user_search_base: String,
/// Group search base (e.g., ou=groups,dc=example,dc=com)
pub group_search_base: String,
/// User object class filter (default: (objectClass=person))
pub user_filter: String,
/// Group object class filter (default: (objectClass=group))
pub group_filter: String,
/// User ID attribute (default: uid for OpenLDAP, sAMAccountName for AD)
pub user_id_attr: String,
/// User groups attribute (default: memberOf for AD)
pub user_groups_attr: String,
/// Home directory attribute (default: homeDirectory)
pub home_dir_attr: String,
/// Default home directory path prefix
pub home_dir_prefix: String,
}
impl Default for LdapConfig {
fn default() -> Self {
Self {
ldap_url: "ldap://localhost:389".to_string(),
base_dn: "dc=example,dc=com".to_string(),
bind_dn: "cn=admin,dc=example,dc=com".to_string(),
bind_password: "".to_string(),
user_search_base: "ou=users,dc=example,dc=com".to_string(),
group_search_base: "ou=groups,dc=example,dc=com".to_string(),
user_filter: "(objectClass=person)".to_string(),
group_filter: "(objectClass=group)".to_string(),
user_id_attr: "uid".to_string(),
user_groups_attr: "memberOf".to_string(),
home_dir_attr: "homeDirectory".to_string(),
home_dir_prefix: "/home".to_string(),
}
}
}
impl LdapConfig {
/// Create Active Directory configuration
pub fn for_ad(ldap_url: String, base_dn: String, bind_dn: String, bind_password: String) -> Self {
let user_search_base = base_dn.clone();
let group_search_base = base_dn.clone();
Self {
ldap_url,
base_dn,
bind_dn,
bind_password,
user_search_base,
group_search_base,
user_filter: "(objectClass=user)".to_string(),
group_filter: "(objectClass=group)".to_string(),
user_id_attr: "sAMAccountName".to_string(),
user_groups_attr: "memberOf".to_string(),
home_dir_attr: "homeDirectory".to_string(),
home_dir_prefix: "/home".to_string(),
}
}
}
/// LDAP Provider (uses blocking wrapper for async LDAP operations)
pub struct LdapProvider {
config: LdapConfig,
}
impl LdapProvider {
pub fn new(config: LdapConfig) -> Self {
Self { config }
}
/// Async implementation of get_user (internal)
async fn get_user_async(&self, username: &str) -> Result<Option<User>, ProviderError> {
use ldap3::{Ldap, LdapConnAsync, Scope, SearchEntry};
// Connect to LDAP
let (conn, mut ldap) = LdapConnAsync::new(&self.config.ldap_url).await
.map_err(|e| ProviderError::Internal(format!("LDAP connection failed: {}", e)))?;
ldap3::drive!(conn);
// Bind with admin credentials
ldap.simple_bind(&self.config.bind_dn, &self.config.bind_password).await
.map_err(|e| ProviderError::AuthFailed(format!("LDAP bind failed: {}", e)))?;
// Search for user
let filter = format!("(&{}({}={}))",
self.config.user_filter,
self.config.user_id_attr,
username
);
let result = ldap
.search(
&self.config.user_search_base,
Scope::Subtree,
&filter,
&[
&self.config.user_id_attr,
&self.config.user_groups_attr,
&self.config.home_dir_attr,
"uidNumber",
"gidNumber",
],
)
.await
.map_err(|e| ProviderError::Internal(format!("LDAP search failed: {}", e)))?;
let (entries, _controls) = result.success()
.map_err(|e| ProviderError::Internal(format!("LDAP search result error: {}", e)))?;
ldap.unbind().await.ok();
if entries.is_empty() {
return Ok(None);
}
// Parse first entry using SearchEntry::construct()
let search_entry = SearchEntry::construct(entries.into_iter().next().unwrap());
// Extract attributes
let home_dir = search_entry.attrs.get(&self.config.home_dir_attr)
.and_then(|v| v.first().cloned())
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(&self.config.home_dir_prefix).join(username)
});
let uid = search_entry.attrs.get("uidNumber")
.and_then(|v| v.first().and_then(|s| s.parse().ok()))
.unwrap_or(1000);
let gid = search_entry.attrs.get("gidNumber")
.and_then(|v| v.first().and_then(|s| s.parse().ok()))
.unwrap_or(1000);
info!(username, uid, gid, home_dir = %home_dir.display(), dn = %search_entry.dn, "LDAP user found");
Ok(Some(User {
username: username.to_string(),
password_hash: "".to_string(),
home_dir,
uid,
gid,
permissions: "read-write".to_string(),
status: 1,
}))
}
/// Async implementation of check_password (internal)
async fn check_password_async(&self, username: &str, password: &str) -> Result<bool, ProviderError> {
use ldap3::{LdapConnAsync, Scope, SearchEntry};
// First get user DN
let (conn, mut ldap) = LdapConnAsync::new(&self.config.ldap_url).await
.map_err(|e| ProviderError::Internal(format!("LDAP connection failed: {}", e)))?;
ldap3::drive!(conn);
ldap.simple_bind(&self.config.bind_dn, &self.config.bind_password).await
.map_err(|e| ProviderError::AuthFailed(format!("LDAP bind failed: {}", e)))?;
let filter = format!("(&{}({}={}))",
self.config.user_filter,
self.config.user_id_attr,
username
);
let result = ldap
.search(
&self.config.user_search_base,
Scope::Subtree,
&filter,
&["dn"],
)
.await
.map_err(|e| ProviderError::Internal(format!("LDAP search failed: {}", e)))?;
let (entries, _controls) = result.success()
.map_err(|e| ProviderError::Internal(format!("LDAP search result error: {}", e)))?;
if entries.is_empty() {
ldap.unbind().await.ok();
return Ok(false);
}
let search_entry = SearchEntry::construct(entries.into_iter().next().unwrap());
let user_dn = search_entry.dn;
ldap.unbind().await.ok();
// Try to bind as the user
let (conn2, mut user_ldap) = LdapConnAsync::new(&self.config.ldap_url).await
.map_err(|e| ProviderError::Internal(format!("LDAP connection failed: {}", e)))?;
ldap3::drive!(conn2);
let result = user_ldap.simple_bind(&user_dn, password).await;
user_ldap.unbind().await.ok();
match result {
Ok(_) => {
debug!(username, "LDAP password verification successful");
Ok(true)
}
Err(e) => {
debug!(username, error = %e, "LDAP password verification failed");
Ok(false)
}
}
}
/// Async implementation of get_user_groups (internal)
async fn get_user_groups_async(&self, username: &str) -> Result<Vec<String>, ProviderError> {
use ldap3::{LdapConnAsync, Scope, SearchEntry};
let (conn, mut ldap) = LdapConnAsync::new(&self.config.ldap_url).await
.map_err(|e| ProviderError::Internal(format!("LDAP connection failed: {}", e)))?;
ldap3::drive!(conn);
ldap.simple_bind(&self.config.bind_dn, &self.config.bind_password).await
.map_err(|e| ProviderError::AuthFailed(format!("LDAP bind failed: {}", e)))?;
let filter = format!("(&{}({}={}))",
self.config.user_filter,
self.config.user_id_attr,
username
);
let result = ldap
.search(
&self.config.user_search_base,
Scope::Subtree,
&filter,
&[&self.config.user_groups_attr],
)
.await
.map_err(|e| ProviderError::Internal(format!("LDAP search failed: {}", e)))?;
let (entries, _controls) = result.success()
.map_err(|e| ProviderError::Internal(format!("LDAP search result error: {}", e)))?;
ldap.unbind().await.ok();
if entries.is_empty() {
return Ok(Vec::new());
}
let search_entry = SearchEntry::construct(entries.into_iter().next().unwrap());
let attrs = search_entry.attrs;
let groups: Vec<String> = attrs
.get(&self.config.user_groups_attr)
.map(|v| v.clone())
.unwrap_or_default();
// Extract group names from DN (e.g., CN=group1,OU=groups,DC=example,DC=com -> group1)
let group_names: Vec<String> = groups.iter()
.filter_map(|dn| {
dn.split(',')
.next()
.and_then(|s| s.strip_prefix("CN="))
.map(|s| s.to_string())
})
.collect();
debug!(username, groups = ?group_names, "LDAP groups found");
Ok(group_names)
}
}
impl DataProvider for LdapProvider {
fn get_user(&self, username: &str) -> Result<Option<User>, ProviderError> {
// Use tokio runtime to run async operation
let config = self.config.clone();
let username = username.to_string();
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let provider = LdapProvider::new(config);
provider.get_user_async(&username).await
})
})
}
fn check_password(&self, username: &str, password: &str) -> Result<bool, ProviderError> {
let config = self.config.clone();
let username = username.to_string();
let password = password.to_string();
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let provider = LdapProvider::new(config);
provider.check_password_async(&username, &password).await
})
})
}
fn get_home_dir(&self, username: &str) -> Result<Option<String>, ProviderError> {
let user = self.get_user(username)?;
Ok(user.map(|u| u.home_dir.to_string_lossy().to_string()))
}
fn get_user_groups(&self, username: &str) -> Result<Vec<String>, ProviderError> {
let config = self.config.clone();
let username = username.to_string();
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let provider = LdapProvider::new(config);
provider.get_user_groups_async(&username).await
})
})
}
fn get_public_keys(&self, username: &str) -> Result<Vec<String>, ProviderError> {
// LDAP typically doesn't store SSH public keys
debug!(username, "LDAP provider doesn't support public keys");
Ok(Vec::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ldap_config_default() {
let config = LdapConfig::default();
assert_eq!(config.user_id_attr, "uid");
assert_eq!(config.user_groups_attr, "memberOf");
}
#[test]
fn test_ldap_config_for_ad() {
let config = LdapConfig::for_ad(
"ldap://ad.example.com:389".to_string(),
"dc=example,dc=com".to_string(),
"cn=admin,dc=example,dc=com".to_string(),
"password".to_string(),
);
assert_eq!(config.user_id_attr, "sAMAccountName");
assert_eq!(config.user_filter, "(objectClass=user)");
}
}

View File

@@ -1,8 +1,12 @@
pub mod pg;
pub mod sqlite;
#[cfg(feature = "ldap")]
pub mod ldap;
pub use pg::PgProvider;
pub use sqlite::SqliteProvider;
#[cfg(feature = "ldap")]
pub use ldap::{LdapProvider, LdapConfig};
use std::path::PathBuf;