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::extract::State;
use axum_client_ip::InsecureClientIp; use axum_client_ip::InsecureClientIp;
use futures::{StreamExt, TryFutureExt}; use futures::{FutureExt, StreamExt, TryFutureExt};
use ruma::{ use ruma::{
OwnedUserId, UserId, OwnedUserId, UserId,
api::client::{ api::client::{
@@ -52,6 +52,7 @@ pub(crate) async fn get_login_types_route(
/// Authenticates the given user by its ID and its password. /// Authenticates the given user by its ID and its password.
/// ///
/// Returns the user ID if successful, and an error otherwise. /// Returns the user ID if successful, and an error otherwise.
#[tracing::instrument(skip_all, fields(%user_id), name = "password")]
async fn password_login( async fn password_login(
services: &Services, services: &Services,
user_id: &UserId, 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 /// Creates the user if the user is found in the LDAP and do not already have an
/// account. /// account.
#[tracing::instrument(skip_all, fields(%user_id), name = "ldap")]
async fn ldap_login( async fn ldap_login(
services: &Services, services: &Services,
user_id: &UserId, user_id: &UserId,
lowercased_user_id: &UserId, lowercased_user_id: &UserId,
password: &str, password: &str,
) -> Result<OwnedUserId> { ) -> 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}") => | 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"); debug!("Searching user in LDAP");
@@ -102,11 +104,11 @@ async fn ldap_login(
return Err!(Ldap("LDAP search returned two or more results")); 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; 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?; .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) Ok(user_id)
} }
@@ -192,9 +211,13 @@ pub(crate) async fn login_route(
} }
if cfg!(feature = "ldap") && services.config.ldap.enable { 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 { } 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 }) => { | 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 /// 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 /// entered username. In such case, the password used to bind will be the
/// one provided for the login and not the one given by /// 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 /// example: "cn=ldap-reader,dc=example,dc=org" or
/// "cn={username},ou=users,dc=example,dc=org" /// "cn={username},ou=users,dc=example,dc=org"
@@ -1930,6 +1931,9 @@ pub struct LdapConfig {
/// Search filter to limit user searches. /// 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))" /// example: "(&(objectClass=person)(memberOf=matrix))"
/// ///
/// default: "(objectClass=*)" /// default: "(objectClass=*)"
@@ -1959,6 +1963,26 @@ pub struct LdapConfig {
/// default: "givenName" /// default: "givenName"
#[serde(default = "default_ldap_name_attribute")] #[serde(default = "default_ldap_name_attribute")]
pub name_attribute: String, 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)] #[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")] #[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 itertools::Itertools;
use ldap3::{LdapConnAsync, Scope, SearchEntry}; use ldap3::{LdapConnAsync, Scope, SearchEntry};
use tuwunel_core::{debug, error, result::LogErr}; 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 config = &self.services.server.config.ldap;
let uri = config let uri = config
.uri .uri
@@ -1215,18 +1222,18 @@ impl Service {
let attr = [&config.uid_attribute, &config.name_attribute]; 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 let (entries, _result) = ldap
.search(&config.base_dn, Scope::Subtree, filter, &attr) .search(&config.base_dn, Scope::Subtree, user_filter, &attr)
.await .await
.and_then(ldap3::SearchResult::success) .and_then(ldap3::SearchResult::success)
.inspect(|(entries, result)| trace!(?entries, ?result, "LDAP Search")) .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 mut dns = entries
let lowercased_localpart = localpart.to_lowercase();
let dns = entries
.into_iter() .into_iter()
.filter_map(|entry| { .filter_map(|entry| {
let search_entry = SearchEntry::construct(entry); let search_entry = SearchEntry::construct(entry);
@@ -1237,10 +1244,50 @@ impl Service {
.into_iter() .into_iter()
.chain(search_entry.attrs.get(&config.name_attribute)) .chain(search_entry.attrs.get(&config.name_attribute))
.any(|ids| ids.contains(&localpart) || ids.contains(&lowercased_localpart)) .any(|ids| ids.contains(&localpart) || ids.contains(&lowercased_localpart))
.then_some(search_entry.dn) .then_some((search_entry.dn, false))
}) })
.collect_vec(); .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() ldap.unbind()
.await .await
.map_err(|e| err!(Ldap(error!("LDAP unbind error: {e}"))))?; .map_err(|e| err!(Ldap(error!("LDAP unbind error: {e}"))))?;
@@ -1251,7 +1298,7 @@ impl Service {
} }
#[cfg(not(feature = "ldap"))] #[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")) Err!(FeatureDisabled("ldap"))
} }

View File

@@ -1651,7 +1651,8 @@
# You can use the variable `{username}` that will be replaced by the # 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 # entered username. In such case, the password used to bind will be the
# one provided for the login and not the one given by # 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 # example: "cn=ldap-reader,dc=example,dc=org" or
# "cn={username},ou=users,dc=example,dc=org" # "cn={username},ou=users,dc=example,dc=org"
@@ -1667,6 +1668,9 @@
# Search filter to limit user searches. # 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))" # example: "(&(objectClass=person)(memberOf=matrix))"
# #
#filter = "(objectClass=*)" #filter = "(objectClass=*)"
@@ -1688,3 +1692,23 @@
# example: "givenName" or "sn" # example: "givenName" or "sn"
# #
#name_attribute = "givenName" #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