Skip to main content

tuwunel_service/users/
ldap.rs

1#![cfg(feature = "ldap")]
2
3use std::{collections::HashMap, time::Duration};
4
5use ldap3::{
6	Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry, SearchOptions, dn_escape,
7	ldap_escape,
8};
9use ruma::UserId;
10use tokio::{fs::read as read_file, task::JoinHandle};
11use tuwunel_core::{Result, debug, err, error, implement, result::LogErr, trace};
12
13/// Cap LDAP connection setup so a hung directory cannot pin a login attempt.
14const CONN_TIMEOUT: Duration = Duration::from_secs(10);
15
16/// Cap a directory search (in seconds) so a broad filter cannot make one login
17/// scan the whole subtree unbounded.
18const SEARCH_TIMELIMIT: i32 = 10;
19
20/// Performs a LDAP search for the given user.
21///
22/// Returns the list of matching users, with a boolean for each result set
23/// to true if the user is an admin.
24#[implement(super::Service)]
25pub async fn search_ldap(&self, user_id: &UserId) -> Result<Vec<(String, bool)>> {
26	let localpart = user_id.localpart().to_owned();
27	let lowercased_localpart = localpart.to_lowercase();
28
29	let config = &self.services.config.ldap;
30
31	let (driver, mut ldap) = self.ldap_connect().await?;
32
33	match (&config.bind_dn, &config.bind_password_file) {
34		| (Some(bind_dn), Some(bind_password_file)) => {
35			let bind_pw = String::from_utf8(read_file(bind_password_file).await?)?;
36
37			ldap.simple_bind(bind_dn, bind_pw.trim())
38				.await
39				.and_then(ldap3::LdapResult::success)
40				.map_err(|e| {
41					error!(%e, "LDAP bind error");
42					err!(Ldap("LDAP bind failed"))
43				})?;
44		},
45		| (..) => {},
46	}
47
48	let attr = [&config.uid_attribute];
49
50	let escaped_localpart = ldap_escape(&lowercased_localpart);
51
52	let user_filter = &config
53		.filter
54		.replace("{username}", &escaped_localpart);
55
56	ldap.with_search_options(SearchOptions::new().timelimit(SEARCH_TIMELIMIT));
57
58	let (entries, _result) = ldap
59		.search(&config.base_dn, Scope::Subtree, user_filter, &attr)
60		.await
61		.and_then(ldap3::SearchResult::success)
62		.inspect(|(entries, result)| trace!(?entries, ?result, "LDAP Search"))
63		.map_err(|e| {
64			error!(?attr, ?user_filter, %e, "LDAP search error");
65			err!(Ldap("LDAP search failed"))
66		})?;
67
68	let mut dns: HashMap<String, bool> = entries
69		.into_iter()
70		.filter_map(|entry| {
71			let search_entry = SearchEntry::construct(entry);
72			debug!(?search_entry, "LDAP search entry");
73			search_entry
74				.attrs
75				.get(&config.uid_attribute)
76				.is_some_and(|ids| {
77					ids.contains(&localpart) || ids.contains(&lowercased_localpart)
78				})
79				.then_some((search_entry.dn, false))
80		})
81		.collect();
82
83	if !config.admin_filter.is_empty() {
84		let admin_base_dn = if config.admin_base_dn.is_empty() {
85			&config.base_dn
86		} else {
87			&config.admin_base_dn
88		};
89
90		let admin_filter = &config
91			.admin_filter
92			.replace("{username}", &escaped_localpart);
93
94		ldap.with_search_options(SearchOptions::new().timelimit(SEARCH_TIMELIMIT));
95
96		let (admin_entries, _result) = ldap
97			.search(admin_base_dn, Scope::Subtree, admin_filter, &attr)
98			.await
99			.and_then(ldap3::SearchResult::success)
100			.inspect(|(entries, result)| trace!(?entries, ?result, "LDAP Admin Search"))
101			.map_err(|e| {
102				error!(?attr, ?admin_filter, %e, "LDAP admin search error");
103				err!(Ldap("LDAP admin search failed"))
104			})?;
105
106		dns.extend(admin_entries.into_iter().filter_map(|entry| {
107			let search_entry = SearchEntry::construct(entry);
108			debug!(?search_entry, "LDAP search entry");
109			search_entry
110				.attrs
111				.get(&config.uid_attribute)
112				.is_some_and(|ids| {
113					ids.contains(&localpart) || ids.contains(&lowercased_localpart)
114				})
115				.then_some((search_entry.dn, true))
116		}));
117	}
118
119	ldap.unbind().await.map_err(|e| {
120		error!(%e, "LDAP unbind error");
121		err!(Ldap("LDAP unbind failed"))
122	})?;
123
124	driver.await.log_err().ok();
125
126	Ok(dns.drain().collect())
127}
128
129#[implement(super::Service)]
130pub async fn auth_ldap(&self, user_dn: &str, password: &str) -> Result {
131	// An empty password performs an unauthenticated bind (RFC 4513 5.1.2).
132	if password.trim().is_empty() {
133		return Err(err!(Request(Forbidden(debug_error!(
134			"LDAP authentication error: empty password"
135		)))));
136	}
137
138	let (driver, mut ldap) = self.ldap_connect().await?;
139
140	ldap.simple_bind(user_dn, password)
141		.await
142		.and_then(ldap3::LdapResult::success)
143		.map_err(|e| {
144			debug!(%e, "LDAP authentication error");
145			err!(Request(Forbidden("Invalid username or password.")))
146		})?;
147
148	ldap.unbind().await.map_err(|e| {
149		error!(%e, "LDAP unbind error");
150		err!(Ldap("LDAP unbind failed"))
151	})?;
152
153	driver.await.log_err().ok();
154
155	Ok(())
156}
157
158#[implement(super::Service)]
159async fn ldap_connect(&self) -> Result<(JoinHandle<()>, Ldap)> {
160	let uri = self
161		.services
162		.config
163		.ldap
164		.uri
165		.as_ref()
166		.ok_or_else(|| err!(Ldap(error!("LDAP URI is not configured."))))?;
167
168	if uri.scheme().starts_with("ldaps") {
169		self.services.globals.init_rustls_provider()?;
170	}
171
172	let settings = LdapConnSettings::new()
173		.set_conn_timeout(CONN_TIMEOUT)
174		.set_no_tls_verify(
175			self.services
176				.config
177				.allow_invalid_tls_certificates,
178		);
179
180	debug!(?uri, "LDAP creating connection...");
181	let (conn, ldap) = LdapConnAsync::from_url_with_settings(settings, uri)
182		.await
183		.map_err(|e| {
184			error!(%e, "LDAP connection setup error");
185			err!(Ldap("LDAP connection failed"))
186		})?;
187
188	let driver = self.services.server.runtime().spawn(async move {
189		match conn.drive().await {
190			| Err(e) => error!("LDAP connection error: {e}"),
191			| Ok(()) => debug!("LDAP connection completed."),
192		}
193	});
194
195	Ok((driver, ldap))
196}
197
198/// Builds the user bind DN by substituting the escaped localpart into the
199/// configured `bind_dn` template, or `None` when no `{username}` template is
200/// set.
201#[implement(super::Service)]
202#[must_use]
203pub fn ldap_bind_dn(&self, localpart: &str) -> Option<String> {
204	self.services
205		.server
206		.config
207		.ldap
208		.bind_dn
209		.as_ref()
210		.filter(|template| template.contains("{username}"))
211		.map(|template| template.replace("{username}", &dn_escape(localpart)))
212}