feat: add ldap config

feat: add LDAP login and user creation

feat: add diagnostic commands

Co-authored-by: Jason Volk <jason@zemos.net>
Signed-off-by: Jason Volk <jason@zemos.net>
This commit is contained in:
RatCornu
2025-04-19 23:34:52 +02:00
committed by Jason Volk
parent 78a02edbbf
commit 448ac63a21
15 changed files with 804 additions and 165 deletions

489
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -549,6 +549,11 @@ features = ["serde"]
version = "2.0.1"
default-features = false
[workspace.dependencies.ldap3]
version = "0.11"
default-features = false
features = ["sync", "tls-rustls"]
#
# Patches
#

View File

@@ -94,6 +94,39 @@ pub(crate) enum UsersCommand {
user_a: OwnedUserId,
user_b: OwnedUserId,
},
SearchLdap {
user_id: OwnedUserId,
},
AuthLdap {
user_dn: String,
password: String,
},
}
#[admin_command]
async fn auth_ldap(&self, user_dn: String, password: String) -> Result {
let timer = tokio::time::Instant::now();
let result = self
.services
.users
.auth_ldap(&user_dn, &password)
.await;
let query_time = timer.elapsed();
self.write_str(&format!("Query completed in {query_time:?}:\n\n```rs\n{result:#?}\n```"))
.await
}
#[admin_command]
async fn search_ldap(&self, user_id: OwnedUserId) -> Result {
let timer = tokio::time::Instant::now();
let result = self.services.users.search_ldap(&user_id).await;
let query_time = timer.elapsed();
self.write_str(&format!("Query completed in {query_time:?}:\n\n```rs\n{result:#?}\n```"))
.await
}
#[admin_command]

View File

@@ -372,7 +372,10 @@ pub(crate) async fn register_route(
let password = if is_guest { None } else { body.password.as_deref() };
// Create user
services.users.create(&user_id, password, None).await?;
services
.users
.create(&user_id, password, None)
.await?;
// Default to pretty displayname
let mut displayname = user_id.localpart().to_owned();

View File

@@ -90,7 +90,10 @@ pub(crate) async fn get_displayname_route(
.await
{
if !services.users.exists(&body.user_id).await {
services.users.create(&body.user_id, None, None).await?;
services
.users
.create(&body.user_id, None, None)
.await?;
}
services
@@ -193,7 +196,10 @@ pub(crate) async fn get_avatar_url_route(
.await
{
if !services.users.exists(&body.user_id).await {
services.users.create(&body.user_id, None, None).await?;
services
.users
.create(&body.user_id, None, None)
.await?;
}
services
@@ -255,7 +261,10 @@ pub(crate) async fn get_profile_route(
.await
{
if !services.users.exists(&body.user_id).await {
services.users.create(&body.user_id, None, None).await?;
services
.users
.create(&body.user_id, None, None)
.await?;
}
services

View File

@@ -2,9 +2,9 @@ use std::time::Duration;
use axum::extract::State;
use axum_client_ip::InsecureClientIp;
use futures::StreamExt;
use futures::{StreamExt, TryFutureExt};
use ruma::{
UserId,
OwnedUserId, UserId,
api::client::{
session::{
get_login_token,
@@ -22,10 +22,10 @@ use ruma::{
},
};
use tuwunel_core::{
Err, Error, Result, debug, err, info, utils,
utils::{ReadyExt, hash},
Err, Error, Result, debug, debug_error, err, info, utils,
utils::{hash, stream::ReadyExt},
};
use tuwunel_service::uiaa::SESSION_ID_LENGTH;
use tuwunel_service::{Services, uiaa::SESSION_ID_LENGTH};
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
@@ -49,6 +49,82 @@ 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.
async fn password_login(
services: &Services,
user_id: &UserId,
lowercased_user_id: &UserId,
password: &str,
) -> Result<OwnedUserId> {
let (hash, user_id) = services
.users
.password_hash(user_id)
.map_ok(|hash| (hash, user_id))
.or_else(|_| {
services
.users
.password_hash(lowercased_user_id)
.map_ok(|hash| (hash, lowercased_user_id))
})
.await?;
if hash.is_empty() {
return Err!(Request(UserDeactivated("The user has been deactivated")));
}
hash::verify_password(password, &hash)
.inspect_err(|e| debug_error!("{e}"))
.map_err(|_| err!(Request(Forbidden("Wrong username or password."))))?;
Ok(user_id.to_owned())
}
/// Authenticates the given user through the configured LDAP server.
///
/// Creates the user if the user is found in the LDAP and do not already have an
/// account.
async fn ldap_login(
services: &Services,
user_id: &UserId,
lowercased_user_id: &UserId,
password: &str,
) -> Result<OwnedUserId> {
debug!("Searching user in LDAP");
let dns = services.users.search_ldap(user_id).await?;
if dns.len() >= 2 {
return Err!(Ldap("LDAP search returned two or more results"));
}
let Some(user_dn) = dns.first() else {
return password_login(services, user_id, lowercased_user_id, password).await;
};
// LDAP users are automatically created on first login attempt. This is a very
// common feature that can be seen on many services using a LDAP provider for
// their users (synapse, Nextcloud, Jellyfin, ...).
//
// LDAP users are crated with a dummy password but non empty because an empty
// password is reserved for deactivated accounts. The tuwunel password field
// will never be read to login a LDAP user so it's not an issue.
if !services.users.exists(lowercased_user_id).await {
debug!("Creating user {lowercased_user_id} from LDAP");
services
.users
.create(lowercased_user_id, Some("*"), Some("ldap"))
.await?;
}
services
.users
.auth_ldap(user_dn, password)
.await
.map(|()| lowercased_user_id.to_owned())
}
/// # `POST /_matrix/client/v3/login`
///
/// Authenticates the user and returns an access token it can use in subsequent
@@ -107,43 +183,10 @@ pub(crate) async fn login_route(
return Err!(Request(Unknown("User ID does not belong to this homeserver")));
}
// first try the username as-is
let hash = services
.users
.password_hash(&user_id)
.await
.inspect_err(|e| debug!("{e}"));
match hash {
| Ok(hash) => {
if hash.is_empty() {
return Err!(Request(UserDeactivated("The user has been deactivated")));
}
hash::verify_password(password, &hash)
.inspect_err(|e| debug!("{e}"))
.map_err(|_| err!(Request(Forbidden("Wrong username or password."))))?;
user_id
},
| Err(_e) => {
let hash_lowercased_user_id = services
.users
.password_hash(&lowercased_user_id)
.await
.inspect_err(|e| debug!("{e}"))
.map_err(|_| err!(Request(Forbidden("Wrong username or password."))))?;
if hash_lowercased_user_id.is_empty() {
return Err!(Request(UserDeactivated("The user has been deactivated")));
}
hash::verify_password(password, &hash_lowercased_user_id)
.inspect_err(|e| debug!("{e}"))
.map_err(|_| err!(Request(Forbidden("Wrong username or password."))))?;
lowercased_user_id
},
if services.config.ldap.enable {
ldap_login(&services, &user_id, &lowercased_user_id, password).await?
} else {
password_login(&services, &user_id, &lowercased_user_id, password).await?
}
},
| login::v3::LoginInfo::Token(login::v3::Token { token }) => {

View File

@@ -306,7 +306,10 @@ pub(crate) async fn get_timezone_key_route(
.await
{
if !services.users.exists(&body.user_id).await {
services.users.create(&body.user_id, None, None).await?;
services
.users
.create(&body.user_id, None, None)
.await?;
}
services
@@ -366,7 +369,10 @@ pub(crate) async fn get_profile_key_route(
.await
{
if !services.users.exists(&body.user_id).await {
services.users.create(&body.user_id, None, None).await?;
services
.users
.create(&body.user_id, None, None)
.await?;
}
services

View File

@@ -78,6 +78,7 @@ http-body-util.workspace = true
http.workspace = true
ipaddress.workspace = true
itertools.workspace = true
ldap3.workspace = true
libc.workspace = true
libloading.workspace = true
libloading.optional = true

View File

@@ -52,7 +52,7 @@ use crate::{Result, err, error::Error, utils::sys};
### For more information, see:
### https://tuwunel.chat/configuration.html
"#,
ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates"
ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates ldap"
)]
pub struct Config {
/// The server_name is the pretty name of this server. It is used as a
@@ -1804,6 +1804,17 @@ pub struct Config {
// external structure; separate section
#[serde(default)]
pub blurhashing: BlurhashConfig,
#[cfg(not(doctest))]
/// Examples:
///
/// - No LDAP login (default):
///
/// ldap = "none"
///
/// default: "none"
pub ldap: LdapConfig,
#[serde(flatten)]
#[allow(clippy::zero_sized_map_values)]
// this is a catchall, the map shouldn't be zero at runtime
@@ -1885,6 +1896,75 @@ pub struct BlurhashConfig {
pub blurhash_max_raw_size: u64,
}
#[derive(Clone, Debug, Deserialize)]
#[config_example_generator(filename = "tuwunel-example.toml", section = "global.ldap")]
pub struct LdapConfig {
/// Whether to enable LDAP login.
///
/// example: "true"
#[serde(default)]
pub enable: bool,
/// URI of the LDAP server.
///
/// example: "ldap://ldap.example.com:389"
#[serde(deserialize_with = "crate::utils::deserialize_from_str")]
pub uri: Url,
/// Whether to use StartTLS to bind to the LDAP server.
///
/// example: true
#[serde(default)]
pub start_tls: bool,
/// Root of the searches.
///
/// example: "ou=users,dc=example,dc=org"
#[serde(default)]
pub base_dn: String,
/// Bind DN if anonymous search is not enabled.
///
/// example: "cn=ldap-reader,dc=example,dc=org"
#[serde(default)]
pub bind_dn: Option<String>,
/// Path to a file on the system that contains the password for the
/// `bind_dn`.
///
/// The server must be able to access the file, and it must not be empty.
#[serde(default)]
pub bind_password_file: Option<PathBuf>,
/// Search filter to limit user searches.
///
/// example: "(&(objectClass=person)(memberOf=matrix))"
///
/// default: "(objectClass=*)"
#[serde(default = "default_ldap_search_filter")]
pub filter: String,
/// Attribute to use to uniquely identify the user.
///
/// example: "uid" or "cn"
///
/// default: "uid"
#[serde(default = "default_ldap_uid_attribute")]
pub uid_attribute: String,
/// Attribute containing the mail of the user.
///
/// example: "mail"
#[serde(default = "default_ldap_mail_attribute")]
pub mail_attribute: String,
/// Attribute containing the distinguished name of the user.
///
/// example: "givenName" or "sn"
#[serde(default = "default_ldap_name_attribute")]
pub name_attribute: String,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(transparent)]
struct ListeningPort {
@@ -2267,10 +2347,16 @@ fn default_sender_shutdown_timeout() -> u64 { 5 }
// blurhashing defaults recommended by https://blurha.sh/
// 2^25
pub(super) fn default_blurhash_max_raw_size() -> u64 { 33_554_432 }
fn default_blurhash_max_raw_size() -> u64 { 33_554_432 }
pub(super) fn default_blurhash_x_component() -> u32 { 4 }
fn default_blurhash_x_component() -> u32 { 4 }
pub(super) fn default_blurhash_y_component() -> u32 { 3 }
fn default_blurhash_y_component() -> u32 { 3 }
// end recommended & blurhashing defaults
fn default_ldap_search_filter() -> String { "(objectClass=*)".to_owned() }
fn default_ldap_uid_attribute() -> String { String::from("uid") }
fn default_ldap_mail_attribute() -> String { String::from("mail") }
fn default_ldap_name_attribute() -> String { String::from("name") }

View File

@@ -110,6 +110,8 @@ pub enum Error {
InconsistentRoomState(&'static str, ruma::OwnedRoomId),
#[error(transparent)]
IntoHttp(#[from] ruma::api::error::IntoHttpError),
#[error("{0}")]
Ldap(Cow<'static, str>),
#[error(transparent)]
Mxc(#[from] ruma::MxcUriError),
#[error(transparent)]

View File

@@ -87,6 +87,7 @@ image.workspace = true
image.optional = true
ipaddress.workspace = true
itertools.workspace = true
ldap3.workspace = true
log.workspace = true
loole.workspace = true
lru-cache.workspace = true

View File

@@ -38,7 +38,10 @@ pub async fn create_admin_room(services: &Services) -> Result {
// Create a user for the server
let server_user = services.globals.server_user.as_ref();
services.users.create(server_user, None, None).await?;
services
.users
.create(server_user, None, None)
.await?;
let create_content = {
use RoomVersionId::*;

View File

@@ -133,7 +133,10 @@ impl Service {
#[allow(clippy::collapsible_if)]
if !self.services.globals.user_is_local(user_id) {
if !self.services.users.exists(user_id).await {
self.services.users.create(user_id, None, None).await?;
self.services
.users
.create(user_id, None, None)
.await?;
}
/*

View File

@@ -1,6 +1,8 @@
use std::{collections::BTreeMap, mem, sync::Arc};
use futures::{Stream, StreamExt, TryFutureExt};
use itertools::Itertools;
use ldap3::{LdapConnAsync, Scope, SearchEntry};
use ruma::{
DeviceId, KeyId, MilliSecondsSinceUnixEpoch, OneTimeKeyAlgorithm, OneTimeKeyId,
OneTimeKeyName, OwnedDeviceId, OwnedKeyId, OwnedMxcUri, OwnedUserId, RoomId, UInt, UserId,
@@ -13,8 +15,8 @@ use ruma::{
};
use serde_json::json;
use tuwunel_core::{
Err, Error, Result, Server, at, debug_warn, err, trace,
utils::{self, ReadyExt, stream::TryIgnore, string::Unquoted},
Err, Error, Result, Server, at, debug, debug_warn, err, error, trace,
utils::{self, ReadyExt, result::LogErr, stream::TryIgnore, string::Unquoted},
};
use tuwunel_database::{Deserialized, Ignore, Interfix, Json, Map};
@@ -123,6 +125,10 @@ impl Service {
}
/// Create a new user account on this homeserver.
///
/// User origin is by default "password" (meaning that it will login using
/// its user_id/password). Users with other origins (currently only "ldap"
/// is available) have special login processes.
#[inline]
pub async fn create(
&self,
@@ -222,7 +228,11 @@ impl Service {
/// Returns the origin of the user (password/LDAP/...).
pub async fn origin(&self, user_id: &UserId) -> Result<String> {
self.db.userid_origin.get(user_id).await.deserialized()
self.db
.userid_origin
.get(user_id)
.await
.deserialized()
}
/// Returns the password hash for the given user.
@@ -236,13 +246,17 @@ impl Service {
/// Hash and set the user's password to the Argon2 hash
pub async fn set_password(&self, user_id: &UserId, password: Option<&str>) -> Result<()> {
// Cannot change the password of a LDAP user. There are two special cases :
// - a `None` password can be used to deactivate a LDAP user
// - a "*" password is used as the default password of an active LDAP user
if self
.db
.userid_origin
.get(user_id)
.await
.deserialized::<String>()?
== "ldap"
== "ldap" && password.is_some()
&& password != Some("*")
{
Err!(Request(InvalidParam("Cannot change password of a LDAP user")))
} else {
@@ -1163,6 +1177,96 @@ impl Service {
self.db.useridprofilekey_value.del(key);
}
}
pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<String>> {
let config = &self.services.server.config.ldap;
let (conn, mut ldap) = LdapConnAsync::new(config.uri.as_str())
.await
.map_err(|e| err!(Ldap(error!(?user_id, "LDAP connection setup error: {e}"))))?;
let driver = self.services.server.runtime().spawn(async move {
match conn.drive().await {
| Err(e) => error!("LDAP connection error: {e}"),
| Ok(()) => debug!("LDAP connection completed."),
}
});
match (&config.bind_dn, &config.bind_password_file) {
| (Some(bind_dn), Some(bind_password_file)) => {
let bind_pw = String::from_utf8(std::fs::read(bind_password_file)?)?;
ldap.simple_bind(bind_dn, bind_pw.trim())
.await
.and_then(ldap3::LdapResult::success)
.map_err(|e| err!(Ldap(error!("LDAP bind error: {e}"))))?;
},
| (..) => {},
}
let attr = [&config.uid_attribute, &config.name_attribute];
let filter = &config.filter;
let (entries, _result) = ldap
.search(&config.base_dn, Scope::Subtree, 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}"))))?;
let localpart = user_id.localpart().to_owned();
let lowercased_localpart = localpart.to_lowercase();
let dns = 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)
})
.collect_vec();
ldap.unbind()
.await
.map_err(|e| err!(Ldap(error!("LDAP unbind error: {e}"))))?;
driver.await.log_err().ok();
Ok(dns)
}
pub async fn auth_ldap(&self, user_dn: &str, password: &str) -> Result {
let config = &self.services.server.config.ldap;
let (conn, mut ldap) = LdapConnAsync::new(config.uri.as_str())
.await
.map_err(|e| err!(Ldap(error!(?user_dn, "LDAP connection setup error: {e}"))))?;
let driver = self.services.server.runtime().spawn(async move {
match conn.drive().await {
| Err(e) => error!("LDAP connection error: {e}"),
| Ok(()) => debug!("LDAP connection completed."),
}
});
ldap.simple_bind(user_dn, password)
.await
.and_then(ldap3::LdapResult::success)
.map_err(|e| {
err!(Request(Forbidden(debug_error!("LDAP authentication error: {e}"))))
})?;
ldap.unbind()
.await
.map_err(|e| err!(Ldap(error!("LDAP unbind error: {e}"))))?;
driver.await.log_err().ok();
Ok(())
}
}
pub fn parse_master_key(

View File

@@ -1626,3 +1626,66 @@
# is 33.55MB. Setting it to 0 disables blurhashing.
#
#blurhash_max_raw_size = 33554432
[global.ldap]
# Whether to enable LDAP login.
#
# example: "true"
#
#enable = false
# URI of the LDAP server.
#
# example: "ldap://ldap.example.com:389"
#
#uri =
# Whether to use StartTLS to bind to the LDAP server.
#
# example: true
#
#start_tls = false
# Root of the searches.
#
# example: "ou=users,dc=example,dc=org"
#
#base_dn = false
# Bind DN if anonymous search is not enabled.
#
# example: "cn=ldap-reader,dc=example,dc=org"
#
#bind_dn = false
# Path to a file on the system that contains the password for the
# `bind_dn`.
#
# The server must be able to access the file, and it must not be empty.
#
#bind_password_file = false
# Search filter to limit user searches.
#
# example: "(&(objectClass=person)(memberOf=matrix))"
#
#filter = "(objectClass=*)"
# Attribute to use to uniquely identify the user.
#
# example: "uid" or "cn"
#
#uid_attribute = "uid"
# Attribute containing the mail of the user.
#
# example: "mail"
#
#mail_attribute =
# Attribute containing the distinguished name of the user.
#
# example: "givenName" or "sn"
#
#name_attribute =