feat: add admin support for LDAP login

This commit is contained in:
RatCornu
2025-05-06 21:38:51 +02:00
committed by Jason Volk
parent 824b962b60
commit 71ebf1e71a
4 changed files with 136 additions and 18 deletions

View File

@@ -2,7 +2,7 @@ use std::time::Duration;
use axum::extract::State;
use axum_client_ip::InsecureClientIp;
use futures::{StreamExt, TryFutureExt};
use futures::{FutureExt, StreamExt, TryFutureExt};
use ruma::{
OwnedUserId, UserId,
api::client::{
@@ -52,6 +52,7 @@ pub(crate) async fn get_login_types_route(
/// Authenticates the given user by its ID and its password.
///
/// Returns the user ID if successful, and an error otherwise.
#[tracing::instrument(skip_all, fields(%user_id), name = "password")]
async fn password_login(
services: &Services,
user_id: &UserId,
@@ -85,15 +86,16 @@ async fn password_login(
///
/// Creates the user if the user is found in the LDAP and do not already have an
/// account.
#[tracing::instrument(skip_all, fields(%user_id), name = "ldap")]
async fn ldap_login(
services: &Services,
user_id: &UserId,
lowercased_user_id: &UserId,
password: &str,
) -> Result<OwnedUserId> {
let user_dn = match services.config.ldap.bind_dn.as_ref() {
let (user_dn, is_ldap_admin) = match services.config.ldap.bind_dn.as_ref() {
| Some(bind_dn) if bind_dn.contains("{username}") =>
bind_dn.replace("{username}", lowercased_user_id.localpart()),
(bind_dn.replace("{username}", lowercased_user_id.localpart()), false),
| _ => {
debug!("Searching user in LDAP");
@@ -102,11 +104,11 @@ async fn ldap_login(
return Err!(Ldap("LDAP search returned two or more results"));
}
let Some(user_dn) = dns.first() else {
let Some((user_dn, is_admin)) = dns.first() else {
return password_login(services, user_id, lowercased_user_id, password).await;
};
user_dn.clone()
(user_dn.clone(), *is_admin)
},
};
@@ -130,6 +132,23 @@ async fn ldap_login(
.await?;
}
let is_tuwunel_admin = services
.admin
.user_is_admin(lowercased_user_id)
.await;
if is_ldap_admin && !is_tuwunel_admin {
services
.admin
.make_user_admin(lowercased_user_id)
.await?;
} else if !is_ldap_admin && is_tuwunel_admin {
services
.admin
.revoke_admin(lowercased_user_id)
.await?;
}
Ok(user_id)
}
@@ -192,9 +211,13 @@ pub(crate) async fn login_route(
}
if cfg!(feature = "ldap") && services.config.ldap.enable {
ldap_login(&services, &user_id, &lowercased_user_id, password).await?
ldap_login(&services, &user_id, &lowercased_user_id, password)
.boxed()
.await?
} else {
password_login(&services, &user_id, &lowercased_user_id, password).await?
password_login(&services, &user_id, &lowercased_user_id, password)
.boxed()
.await?
}
},
| login::v3::LoginInfo::Token(login::v3::Token { token }) => {

View File

@@ -1914,7 +1914,8 @@ pub struct LdapConfig {
/// You can use the variable `{username}` that will be replaced by the
/// entered username. In such case, the password used to bind will be the
/// one provided for the login and not the one given by
/// `bind_password_file`.
/// `bind_password_file`. Beware: automatically granting admin rights will
/// not work if you use this direct bind instead of a LDAP search.
///
/// example: "cn=ldap-reader,dc=example,dc=org" or
/// "cn={username},ou=users,dc=example,dc=org"
@@ -1930,6 +1931,9 @@ pub struct LdapConfig {
/// Search filter to limit user searches.
///
/// You can use the variable `{username}` that will be replaced by the
/// entered username for more complex filters.
///
/// example: "(&(objectClass=person)(memberOf=matrix))"
///
/// default: "(objectClass=*)"
@@ -1959,6 +1963,26 @@ pub struct LdapConfig {
/// default: "givenName"
#[serde(default = "default_ldap_name_attribute")]
pub name_attribute: String,
/// Root of the searches for admin users.
///
/// Defaults to `base_dn` if empty.
///
/// example: "ou=admins,dc=example,dc=org"
#[serde(default)]
pub admin_base_dn: String,
/// The LDAP search filter to find administrative users for tuwunel.
///
/// If left blank, administrative state must be configured manually for each
/// user.
///
/// You can use the variable `{username}` that will be replaced by the
/// entered username for more complex filters.
///
/// example: "(objectClass=tuwunelAdmin)" or "(uid={username})"
#[serde(default)]
pub admin_filter: String,
}
#[derive(Deserialize, Clone, Debug)]

View File

@@ -1178,12 +1178,19 @@ impl Service {
}
}
/// Performs a LDAP search for the given user.
///
/// Returns the list of matching users, with a boolean for each result set
/// to true if the user is an admin.
#[cfg(feature = "ldap")]
pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<String>> {
pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<(String, bool)>> {
use itertools::Itertools;
use ldap3::{LdapConnAsync, Scope, SearchEntry};
use tuwunel_core::{debug, error, result::LogErr};
let localpart = user_id.localpart().to_owned();
let lowercased_localpart = localpart.to_lowercase();
let config = &self.services.server.config.ldap;
let uri = config
.uri
@@ -1215,18 +1222,18 @@ impl Service {
let attr = [&config.uid_attribute, &config.name_attribute];
let filter = &config.filter;
let user_filter = &config
.filter
.replace("{username}", &lowercased_localpart);
let (entries, _result) = ldap
.search(&config.base_dn, Scope::Subtree, filter, &attr)
.search(&config.base_dn, Scope::Subtree, user_filter, &attr)
.await
.and_then(ldap3::SearchResult::success)
.inspect(|(entries, result)| trace!(?entries, ?result, "LDAP Search"))
.map_err(|e| err!(Ldap(error!(?attr, ?filter, "LDAP search error: {e}"))))?;
.map_err(|e| err!(Ldap(error!(?attr, ?user_filter, "LDAP search error: {e}"))))?;
let localpart = user_id.localpart().to_owned();
let lowercased_localpart = localpart.to_lowercase();
let dns = entries
let mut dns = entries
.into_iter()
.filter_map(|entry| {
let search_entry = SearchEntry::construct(entry);
@@ -1237,10 +1244,50 @@ impl Service {
.into_iter()
.chain(search_entry.attrs.get(&config.name_attribute))
.any(|ids| ids.contains(&localpart) || ids.contains(&lowercased_localpart))
.then_some(search_entry.dn)
.then_some((search_entry.dn, false))
})
.collect_vec();
if !config.admin_base_dn.is_empty() {
let admin_base_dn = if config.admin_base_dn.is_empty() {
&config.base_dn
} else {
&config.admin_base_dn
};
let admin_filter = &config
.admin_filter
.replace("{username}", &lowercased_localpart);
let (admin_entries, _result) = ldap
.search(admin_base_dn, Scope::Subtree, admin_filter, &attr)
.await
.and_then(ldap3::SearchResult::success)
.inspect(|(entries, result)| trace!(?entries, ?result, "LDAP Admin Search"))
.map_err(|e| {
err!(Ldap(error!(?attr, ?user_filter, "Ldap admin search error: {e}")))
})?;
let mut admin_dns = admin_entries
.into_iter()
.filter_map(|entry| {
let search_entry = SearchEntry::construct(entry);
debug!(?search_entry, "LDAP search entry");
search_entry
.attrs
.get(&config.uid_attribute)
.into_iter()
.chain(search_entry.attrs.get(&config.name_attribute))
.any(|ids| {
ids.contains(&localpart) || ids.contains(&lowercased_localpart)
})
.then_some((search_entry.dn, true))
})
.collect_vec();
dns.append(&mut admin_dns);
}
ldap.unbind()
.await
.map_err(|e| err!(Ldap(error!("LDAP unbind error: {e}"))))?;
@@ -1251,7 +1298,7 @@ impl Service {
}
#[cfg(not(feature = "ldap"))]
pub async fn search_ldap(&self, _user_id: &UserId) -> Result<Vec<String>> {
pub async fn search_ldap(&self, _user_id: &UserId) -> Result<Vec<(String, bool)>> {
Err!(FeatureDisabled("ldap"))
}

View File

@@ -1651,7 +1651,8 @@
# You can use the variable `{username}` that will be replaced by the
# entered username. In such case, the password used to bind will be the
# one provided for the login and not the one given by
# `bind_password_file`.
# `bind_password_file`. Beware: automatically granting admin rights will
# not work if you use this direct bind instead of a LDAP search.
#
# example: "cn=ldap-reader,dc=example,dc=org" or
# "cn={username},ou=users,dc=example,dc=org"
@@ -1667,6 +1668,9 @@
# Search filter to limit user searches.
#
# You can use the variable `{username}` that will be replaced by the
# entered username for more complex filters.
#
# example: "(&(objectClass=person)(memberOf=matrix))"
#
#filter = "(objectClass=*)"
@@ -1688,3 +1692,23 @@
# example: "givenName" or "sn"
#
#name_attribute = "givenName"
# Root of the searches for admin users.
#
# Defaults to `base_dn` if empty.
#
# example: "ou=admins,dc=example,dc=org"
#
#admin_base_dn = false
# The LDAP search filter to find administrative users for tuwunel.
#
# If left blank, administrative state must be configured manually for each
# user.
#
# You can use the variable `{username}` that will be replaced by the
# entered username for more complex filters.
#
# example: "(objectClass=tuwunelAdmin)" or "(uid={username})"
#
#admin_filter = false