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

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(