feat: add admin support for LDAP login
This commit is contained in:
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user