375 lines
12 KiB
Rust
375 lines
12 KiB
Rust
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<Option<LdapIdentity>, 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<String> {
|
|
entry
|
|
.attrs
|
|
.get(name)
|
|
.and_then(|values| values.first())
|
|
.cloned()
|
|
}
|
|
|
|
fn first_attr_or_binary(entry: &SearchEntry, name: &str) -> Option<String> {
|
|
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<SessionResponse, AuthError> {
|
|
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<User, AuthError> {
|
|
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<User, AuthError> {
|
|
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<i64>,
|
|
) -> Result<String, AuthError> {
|
|
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()
|
|
}
|