ldap commit_1
This commit is contained in:
+67
-3
@@ -161,6 +161,9 @@ pub async fn identity(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<IdentityRequest>,
|
||||
) -> Result<Json<IdentityResponse>, AuthError> {
|
||||
if state.ldap.is_some() && req.session_token.is_none() {
|
||||
return Err(AuthError::unauthorized("Log in with your organization account."));
|
||||
}
|
||||
let nickname = validate_nickname(&req.nickname)?;
|
||||
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
||||
match find_user_by_nickname(&state, &nickname).await? {
|
||||
@@ -196,6 +199,9 @@ pub async fn register(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
|
||||
if state.ldap.is_some() {
|
||||
return Err(AuthError::forbidden("Local registration is disabled while LDAP authentication is enabled."));
|
||||
}
|
||||
if !state.registration_enabled {
|
||||
warn!("registration attempt rejected because registration is disabled");
|
||||
return Err(AuthError::forbidden("Registration is disabled."));
|
||||
@@ -324,6 +330,21 @@ pub async fn login(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<SessionResponse>, AuthError> {
|
||||
if let Some(config) = state.ldap.as_ref() {
|
||||
debug!(login = %req.email.trim(), "LDAP login requested");
|
||||
let identity = crate::ldap_auth::authenticate(config, &req.email, &req.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?;
|
||||
let session = create_session(&state, &user).await?;
|
||||
info!(user_id = user.id, nickname = %user.nickname, "LDAP login successful");
|
||||
return Ok(Json(session));
|
||||
}
|
||||
|
||||
let email = validate_email(&req.email)?;
|
||||
debug!(email_domain = %email_domain(&email), "login requested");
|
||||
let user = find_user_by_email(&state, &email)
|
||||
@@ -331,9 +352,7 @@ pub async fn login(
|
||||
.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
||||
if !verify_password(&user.password_hash, &req.password) {
|
||||
warn!(user_id = user.id, "login rejected: invalid password");
|
||||
return Err(AuthError::unauthorized(
|
||||
"Invalid e-mail address or password.",
|
||||
));
|
||||
return Err(AuthError::unauthorized("Invalid e-mail address or password."));
|
||||
}
|
||||
if state.account_confirmation_required && user.confirmed_at.is_none() {
|
||||
return Err(AuthError::forbidden(
|
||||
@@ -345,6 +364,51 @@ pub async fn login(
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
async fn provision_ldap_user(
|
||||
state: &SharedState,
|
||||
identity: crate::ldap_auth::LdapIdentity,
|
||||
) -> Result<User, AuthError> {
|
||||
let email = validate_email(&identity.email)?;
|
||||
if let Some(user) = find_user_by_email(state, &email).await? {
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
let base = truncate_nickname(&identity.nickname);
|
||||
let mut nickname = base.clone();
|
||||
let mut suffix = 1usize;
|
||||
while find_user_by_nickname(state, &nickname).await?.is_some() {
|
||||
suffix += 1;
|
||||
let marker = format!("-{suffix}");
|
||||
nickname = format!("{}{}", truncate_to_chars(&base, MAX_NICKNAME - marker.chars().count()), marker);
|
||||
}
|
||||
let password_hash = hash_password(&random_token())?;
|
||||
let confirmed_at = Utc::now().to_rfc3339();
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER))
|
||||
.bind(&nickname)
|
||||
.bind(normalize(&nickname))
|
||||
.bind(&email)
|
||||
.bind(normalize(&email))
|
||||
.bind(password_hash)
|
||||
.bind(Some(confirmed_at))
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
info!(ldap_username = %identity.username, nickname = %nickname, "provisioned LDAP user");
|
||||
find_user_by_email(state, &email)
|
||||
.await?
|
||||
.ok_or_else(|| AuthError::internal("Failed to provision the organization account."))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
pub async fn confirm_account(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<ConfirmAccountRequest>,
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
use std::{env, net::IpAddr};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthorizationType {
|
||||
Local,
|
||||
Ldap,
|
||||
Ad,
|
||||
}
|
||||
|
||||
impl AuthorizationType {
|
||||
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
match env_var("AUTHORIZATION_TYPE", "local")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"local" => Ok(Self::Local),
|
||||
"ldap" => Ok(Self::Ldap),
|
||||
"ad" => Ok(Self::Ad),
|
||||
_ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Local => "local",
|
||||
Self::Ldap => "ldap",
|
||||
Self::Ad => "ad",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub host: IpAddr,
|
||||
@@ -20,6 +50,8 @@ pub struct Config {
|
||||
pub frontend_log_level: String,
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
pub authorization_type: AuthorizationType,
|
||||
pub ldap: Option<crate::ldap_auth::LdapConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -56,6 +88,34 @@ impl Config {
|
||||
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
|
||||
}
|
||||
|
||||
let authorization_type = AuthorizationType::from_env()?;
|
||||
let ldap = match authorization_type {
|
||||
AuthorizationType::Local => None,
|
||||
AuthorizationType::Ldap | AuthorizationType::Ad => {
|
||||
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
|
||||
let (default_filter, default_username_attribute) = match authorization_type {
|
||||
AuthorizationType::Ldap => ("(uid={username})", "uid"),
|
||||
AuthorizationType::Ad => (
|
||||
"(|(sAMAccountName={username})(userPrincipalName={username}))",
|
||||
"sAMAccountName",
|
||||
),
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
};
|
||||
Some(crate::ldap_auth::LdapConfig {
|
||||
url: required_nonempty_env("LDAP_URL", &context)?,
|
||||
starttls: env_bool("LDAP_STARTTLS", false)?,
|
||||
bind_dn: env::var("LDAP_BIND_DN").unwrap_or_default(),
|
||||
bind_password: env::var("LDAP_BIND_PASSWORD").unwrap_or_default(),
|
||||
base_dn: required_nonempty_env("LDAP_BASE_DN", &context)?,
|
||||
user_filter: env_var("LDAP_USER_FILTER", default_filter),
|
||||
username_attribute: env_var("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
|
||||
email_attribute: env_var("LDAP_EMAIL_ATTRIBUTE", "mail"),
|
||||
display_name_attribute: env_var("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
||||
organization: env_var("LDAP_ORGANIZATION", "organization"),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let smtp_host = std::env::var("SMTP_HOST")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty());
|
||||
@@ -95,6 +155,8 @@ impl Config {
|
||||
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
||||
anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days,
|
||||
authorization_type,
|
||||
ldap,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -141,3 +203,11 @@ fn required_env(name: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn required_nonempty_env(name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let value = env::var(name).map_err(|_| format!("{name} is required when {context}"))?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{name} cannot be empty when {context}").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
|
||||
|
||||
#[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 organization: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LdapIdentity {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub nickname: 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);
|
||||
let (connection, mut ldap) = LdapConnAsync::with_settings(settings, &config.url)
|
||||
.await
|
||||
.map_err(|error| format!("LDAP connection failed: {error}"))?;
|
||||
ldap3::drive!(connection);
|
||||
|
||||
if !config.bind_dn.trim().is_empty() {
|
||||
let result = ldap
|
||||
.simple_bind(&config.bind_dn, &config.bind_password)
|
||||
.await
|
||||
.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(),
|
||||
];
|
||||
let (entries, _) = ldap
|
||||
.search(&config.base_dn, Scope::Subtree, &filter, attributes)
|
||||
.await
|
||||
.map_err(|error| format!("LDAP search failed: {error}"))?
|
||||
.success()
|
||||
.map_err(|error| format!("LDAP search rejected: {error}"))?;
|
||||
|
||||
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('@'))
|
||||
.unwrap_or_else(|| format!("{}@ldap.local", safe_identifier(&username)));
|
||||
let display_name = first_attr(&entry, &config.display_name_attribute)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| username.clone());
|
||||
|
||||
let result = ldap
|
||||
.simple_bind(&user_dn, password)
|
||||
.await
|
||||
.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 organization = config.organization.trim();
|
||||
let nickname = if organization.is_empty() {
|
||||
display_name
|
||||
} else {
|
||||
format!("{organization}/{display_name}")
|
||||
};
|
||||
|
||||
Ok(Some(LdapIdentity {
|
||||
username,
|
||||
email,
|
||||
nickname,
|
||||
}))
|
||||
}
|
||||
|
||||
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
|
||||
entry.attrs.get(name).and_then(|values| values.first()).cloned()
|
||||
}
|
||||
|
||||
fn safe_identifier(value: &str) -> String {
|
||||
let result: String = value
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { ch } else { '_' })
|
||||
.collect();
|
||||
if result.is_empty() { "user".into() } else { result }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+4
-1
@@ -3,6 +3,7 @@ mod app;
|
||||
mod assets;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod ldap_auth;
|
||||
mod database;
|
||||
mod db;
|
||||
mod queries;
|
||||
@@ -43,6 +44,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days = config.user_session_ttl_days,
|
||||
smtp_configured = config.smtp.is_some(),
|
||||
authorization_type = config.authorization_type.as_str(),
|
||||
asset_version = %config.asset_version,
|
||||
"configuration loaded"
|
||||
);
|
||||
@@ -73,12 +75,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
config.upload_max_size_bytes,
|
||||
config.file_cache_max_age_seconds,
|
||||
config.smtp.clone(),
|
||||
config.registration_enabled,
|
||||
config.registration_enabled && config.ldap.is_none(),
|
||||
config.account_confirmation_required,
|
||||
config.share_confirmation_required,
|
||||
config.frontend_log_level.clone(),
|
||||
config.anonymous_access_token_ttl_days,
|
||||
config.user_session_ttl_days,
|
||||
config.ldap.clone(),
|
||||
));
|
||||
let app = app::router(
|
||||
state,
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct AppState {
|
||||
pub frontend_log_level: String,
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
pub ldap: Option<crate::ldap_auth::LdapConfig>,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
||||
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
||||
next_connection_id: AtomicU64,
|
||||
@@ -82,6 +83,7 @@ impl AppState {
|
||||
frontend_log_level: String,
|
||||
anonymous_access_token_ttl_days: i64,
|
||||
user_session_ttl_days: i64,
|
||||
ldap: Option<crate::ldap_auth::LdapConfig>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
@@ -96,6 +98,7 @@ impl AppState {
|
||||
frontend_log_level,
|
||||
anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days,
|
||||
ldap,
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
presence: RwLock::new(HashMap::new()),
|
||||
next_connection_id: AtomicU64::new(1),
|
||||
|
||||
Reference in New Issue
Block a user