From 71ebf1e71a4db94c9cc94cc7c68685582154a706 Mon Sep 17 00:00:00 2001 From: RatCornu Date: Tue, 6 May 2025 21:38:51 +0200 Subject: [PATCH] feat: add admin support for LDAP login --- src/api/client/session.rs | 37 +++++++++++++++++----- src/core/config/mod.rs | 26 +++++++++++++++- src/service/users/mod.rs | 65 +++++++++++++++++++++++++++++++++------ tuwunel-example.toml | 26 +++++++++++++++- 4 files changed, 136 insertions(+), 18 deletions(-) diff --git a/src/api/client/session.rs b/src/api/client/session.rs index f06af22a..ede4a80f 100644 --- a/src/api/client/session.rs +++ b/src/api/client/session.rs @@ -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 { - 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 }) => { diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 1c564c24..9b992f4c 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -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)] diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 3afb8964..c8c90792 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -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> { + pub async fn search_ldap(&self, user_id: &UserId) -> Result> { 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> { + pub async fn search_ldap(&self, _user_id: &UserId) -> Result> { Err!(FeatureDisabled("ldap")) } diff --git a/tuwunel-example.toml b/tuwunel-example.toml index ed10041c..97a6d9d8 100644 --- a/tuwunel-example.toml +++ b/tuwunel-example.toml @@ -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