use chrono::Utc; use tracing::{debug, info, warn}; use crate::{queries, state::SharedState}; use super::{ AuthError, MAX_NICKNAME, SessionResponse, User, create_session, find_user_by_email, find_user_by_external_id, find_user_by_nickname, hash_password, normalize, random_token, validate_email, }; use std::time::Duration; use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry}; use tokio::time::timeout; #[derive(Debug, Clone)] pub struct LdapConfig { pub url: String, pub starttls: bool, pub bind_dn: String, pub bind_password: String, pub base_dn: String, pub user_filter: String, pub username_attribute: String, pub email_attribute: String, pub display_name_attribute: String, pub external_id_attribute: String, pub organization: String, pub provider: String, pub email_required: bool, pub link_existing_by_email: bool, pub tls_verify: bool, pub connect_timeout_seconds: u64, pub operation_timeout_seconds: u64, } #[derive(Debug, Clone)] pub struct LdapIdentity { pub username: String, pub email: String, pub nickname: String, pub display_name: String, pub provider: String, pub external_id: String, pub external_dn: String, } pub async fn authenticate( config: &LdapConfig, login: &str, password: &str, ) -> Result, String> { if login.trim().is_empty() || password.is_empty() { return Ok(None); } let settings = LdapConnSettings::new() .set_starttls(config.starttls) .set_no_tls_verify(!config.tls_verify); let connect_timeout = Duration::from_secs(config.connect_timeout_seconds); let operation_timeout = Duration::from_secs(config.operation_timeout_seconds); let (connection, mut ldap) = timeout( connect_timeout, LdapConnAsync::with_settings(settings, &config.url), ) .await .map_err(|_| "LDAP connection timed out".to_owned())? .map_err(|error| format!("LDAP connection failed: {error}"))?; ldap3::drive!(connection); if !config.bind_dn.trim().is_empty() { let result = timeout( operation_timeout, ldap.simple_bind(&config.bind_dn, &config.bind_password), ) .await .map_err(|_| "LDAP service bind timed out".to_owned())? .map_err(|error| format!("LDAP service bind failed: {error}"))?; result .success() .map_err(|error| format!("LDAP service bind rejected: {error}"))?; } let escaped = escape_filter(login.trim()); let filter = config.user_filter.replace("{username}", &escaped); let attributes = vec![ config.username_attribute.as_str(), config.email_attribute.as_str(), config.display_name_attribute.as_str(), config.external_id_attribute.as_str(), ]; let (mut entries, _) = timeout( operation_timeout, ldap.search(&config.base_dn, Scope::Subtree, &filter, attributes.clone()), ) .await .map_err(|_| "LDAP search timed out".to_owned())? .map_err(|error| format!("LDAP search failed: {error}"))? .success() .map_err(|error| format!("LDAP search rejected: {error}"))?; if entries.is_empty() && login.trim().contains('@') { let email_filter = format!("({}={})", config.email_attribute, escaped); let (email_entries, _) = timeout( operation_timeout, ldap.search(&config.base_dn, Scope::Subtree, &email_filter, attributes), ) .await .map_err(|_| "LDAP e-mail search timed out".to_owned())? .map_err(|error| format!("LDAP e-mail search failed: {error}"))? .success() .map_err(|error| format!("LDAP e-mail search rejected: {error}"))?; entries = email_entries; } if entries.len() != 1 { let _ = ldap.unbind().await; return Ok(None); } let entry = SearchEntry::construct(entries.into_iter().next().unwrap()); let user_dn = entry.dn.clone(); let username = first_attr(&entry, &config.username_attribute).unwrap_or_else(|| login.trim().to_owned()); let email = first_attr(&entry, &config.email_attribute) .filter(|value| value.contains('@')) .or_else(|| { (!config.email_required && login.trim().contains('@')).then(|| login.trim().to_owned()) }); if email.is_none() { let _ = ldap.unbind().await; return Err(format!( "LDAP user is missing required e-mail attribute {}", config.email_attribute )); } let email = email.unwrap_or_default(); let external_id = first_attr_or_binary(&entry, &config.external_id_attribute) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { format!( "LDAP user is missing required external identifier attribute {}", config.external_id_attribute ) })?; let display_name = first_attr(&entry, &config.display_name_attribute) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| username.clone()); let result = timeout(operation_timeout, ldap.simple_bind(&user_dn, password)) .await .map_err(|_| "LDAP user bind timed out".to_owned())? .map_err(|error| format!("LDAP user bind failed: {error}"))?; if result.success().is_err() { let _ = ldap.unbind().await; return Ok(None); } let _ = ldap.unbind().await; let nickname = directory_nickname(&display_name, &email, &username); Ok(Some(LdapIdentity { username, email, nickname, display_name, provider: config.provider.clone(), external_id, external_dn: user_dn, })) } fn directory_nickname(display_name: &str, email: &str, username: &str) -> String { let words: Vec<&str> = display_name .split_whitespace() .filter(|v| !v.is_empty()) .collect(); let candidate = if words.len() >= 2 { let first = words[0].chars().next().unwrap_or('u'); format!("{}.{}", first, words[words.len() - 1]) } else { email .split('@') .next() .filter(|v| !v.is_empty()) .unwrap_or(username) .to_owned() }; candidate.to_lowercase() } fn first_attr(entry: &SearchEntry, name: &str) -> Option { entry .attrs .get(name) .and_then(|values| values.first()) .cloned() } fn first_attr_or_binary(entry: &SearchEntry, name: &str) -> Option { first_attr(entry, name).or_else(|| { entry .bin_attrs .get(name) .and_then(|values| values.first()) .map(|value| { let mut output = String::with_capacity(value.len() * 2); for byte in value { use std::fmt::Write; let _ = write!(&mut output, "{byte:02x}"); } output }) }) } fn escape_filter(value: &str) -> String { let mut result = String::with_capacity(value.len()); for byte in value.bytes() { match byte { b'*' => result.push_str("\\2a"), b'(' => result.push_str("\\28"), b')' => result.push_str("\\29"), b'\\' => result.push_str("\\5c"), 0 => result.push_str("\\00"), value if value < 0x20 || value >= 0x7f => result.push_str(&format!("\\{value:02x}")), value => result.push(value as char), } } result } pub(super) async fn login( state: &SharedState, login: &str, password: &str, ) -> Result { let config = state .ldap .as_ref() .ok_or_else(|| AuthError::internal("LDAP authentication is not configured."))?; debug!(login = %login.trim(), "LDAP login requested"); let identity = authenticate(config, login, password) .await .map_err(|error| { tracing::error!(error = %error, "LDAP authentication error"); AuthError::service_unavailable("The organization directory is currently unavailable.") })? .ok_or_else(|| AuthError::unauthorized("Invalid organization login or password."))?; let user = provision_ldap_user(state, identity).await?; if user.is_active == 0 { warn!(user_id = user.id, "LDAP login rejected: inactive account"); return Err(AuthError::forbidden("This account is inactive.")); } let session = create_session(state, &user).await?; info!(user_id = user.id, nickname = %user.nickname, "LDAP login successful"); Ok(session) } async fn provision_ldap_user( state: &SharedState, identity: LdapIdentity, ) -> Result { let email = validate_email(&identity.email)?; if let Some(user) = find_user_by_external_id(state, &identity.provider, &identity.external_id).await? { return sync_directory_user(state, user, &identity, &email).await; } if let Some(existing) = find_user_by_email(state, &email).await? { let link_by_email = state .ldap .as_ref() .map(|config| config.link_existing_by_email) .unwrap_or(false); if !link_by_email { return Err(AuthError::conflict( "This e-mail address already belongs to another account. Ask an administrator to link it or enable LDAP_LINK_EXISTING_BY_EMAIL.", )); } return sync_directory_user(state, existing, &identity, &email).await; } let nickname = available_directory_nickname(state, &identity.nickname, None).await?; let password_hash = hash_password(&random_token())?; let confirmed_at = Utc::now().to_rfc3339(); sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_DIRECTORY_USER, )) .bind(&nickname) .bind(normalize(&nickname)) .bind(&email) .bind(normalize(&email)) .bind(password_hash) .bind(Some(confirmed_at)) .bind(&identity.provider) .bind(&identity.external_id) .bind(&identity.external_dn) .bind(&identity.display_name) .execute(state.db.pool()) .await .map_err(AuthError::database)?; info!(ldap_username = %identity.username, nickname = %nickname, provider = %identity.provider, "provisioned directory user"); find_user_by_external_id(state, &identity.provider, &identity.external_id) .await? .ok_or_else(|| AuthError::internal("Failed to provision the organization account.")) } async fn sync_directory_user( state: &SharedState, user: User, identity: &LdapIdentity, email: &str, ) -> Result { let nickname = available_directory_nickname(state, &identity.nickname, Some(user.id)).await?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_UPDATE_DIRECTORY_USER, )) .bind(&nickname) .bind(normalize(&nickname)) .bind(email) .bind(normalize(email)) .bind(&identity.provider) .bind(&identity.external_id) .bind(&identity.external_dn) .bind(&identity.display_name) .bind(Utc::now().to_rfc3339()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; find_user_by_external_id(state, &identity.provider, &identity.external_id) .await? .ok_or_else(|| AuthError::internal("Failed to synchronize the organization account.")) } async fn available_directory_nickname( state: &SharedState, requested: &str, current_user_id: Option, ) -> Result { let base = truncate_nickname(requested); let mut nickname = base.clone(); let mut suffix = 1usize; loop { match find_user_by_nickname(state, &nickname).await? { None => return Ok(nickname), Some(user) if Some(user.id) == current_user_id => return Ok(nickname), Some(_) => { suffix += 1; let marker = format!("-{suffix}"); nickname = format!( "{}{}", truncate_to_chars(&base, MAX_NICKNAME - marker.chars().count()), marker ); } } } } fn truncate_nickname(value: &str) -> String { let value = value.trim(); let value = if value.is_empty() { "ldap-user" } else { value }; truncate_to_chars(value, MAX_NICKNAME) } fn truncate_to_chars(value: &str, max: usize) -> String { value.chars().take(max).collect() }