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, defer, 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(user_id.as_str()).await?;
32	let abort = driver.abort_handle();
33	defer! {{ abort.abort(); }};
34
35	match (&config.bind_dn, &config.bind_password_file) {
36		| (Some(bind_dn), Some(bind_password_file)) => {
37			let bind_pw = String::from_utf8(read_file(bind_password_file).await?)?;
38
39			ldap.simple_bind(bind_dn, bind_pw.trim())
40				.await
41				.and_then(ldap3::LdapResult::success)
42				.map_err(|e| {
43					error!(%e, "LDAP bind error");
44					err!(Ldap("LDAP bind failed"))
45				})?;
46		},
47		| (..) => {},
48	}
49
50	let attr = [&config.uid_attribute];
51
52	let escaped_localpart = ldap_escape(&lowercased_localpart);
53
54	let user_filter = &config
55		.filter
56		.replace("{username}", &escaped_localpart);
57
58	ldap.with_search_options(SearchOptions::new().timelimit(SEARCH_TIMELIMIT));
59
60	let (entries, _result) = ldap
61		.search(&config.base_dn, Scope::Subtree, user_filter, &attr)
62		.await
63		.and_then(ldap3::SearchResult::success)
64		.inspect(|(entries, result)| trace!(?entries, ?result, "LDAP Search"))
65		.map_err(|e| {
66			error!(?attr, ?user_filter, %e, "LDAP search error");
67			err!(Ldap("LDAP search failed"))
68		})?;
69
70	let mut dns: HashMap<String, bool> = entries
71		.into_iter()
72		.filter_map(|entry| {
73			let search_entry = SearchEntry::construct(entry);
74			debug!(?search_entry, "LDAP search entry");
75			search_entry
76				.attrs
77				.get(&config.uid_attribute)
78				.is_some_and(|ids| {
79					ids.contains(&localpart) || ids.contains(&lowercased_localpart)
80				})
81				.then_some((search_entry.dn, false))
82		})
83		.collect();
84
85	if !config.admin_filter.is_empty() {
86		let admin_base_dn = if config.admin_base_dn.is_empty() {
87			&config.base_dn
88		} else {
89			&config.admin_base_dn
90		};
91
92		let admin_filter = &config
93			.admin_filter
94			.replace("{username}", &escaped_localpart);
95
96		ldap.with_search_options(SearchOptions::new().timelimit(SEARCH_TIMELIMIT));
97
98		let (admin_entries, _result) = ldap
99			.search(admin_base_dn, Scope::Subtree, admin_filter, &attr)
100			.await
101			.and_then(ldap3::SearchResult::success)
102			.inspect(|(entries, result)| trace!(?entries, ?result, "LDAP Admin Search"))
103			.map_err(|e| {
104				error!(?attr, ?admin_filter, %e, "LDAP admin search error");
105				err!(Ldap("LDAP admin search failed"))
106			})?;
107
108		dns.extend(admin_entries.into_iter().filter_map(|entry| {
109			let search_entry = SearchEntry::construct(entry);
110			debug!(?search_entry, "LDAP search entry");
111			search_entry
112				.attrs
113				.get(&config.uid_attribute)
114				.is_some_and(|ids| {
115					ids.contains(&localpart) || ids.contains(&lowercased_localpart)
116				})
117				.then_some((search_entry.dn, true))
118		}));
119	}
120
121	ldap.unbind().await.map_err(|e| {
122		error!(%e, "LDAP unbind error");
123		err!(Ldap("LDAP unbind failed"))
124	})?;
125
126	driver.await.log_err().ok();
127
128	Ok(dns.drain().collect())
129}
130
131#[implement(super::Service)]
132pub async fn auth_ldap(&self, user_dn: &str, password: &str) -> Result {
133	// An empty password performs an unauthenticated bind (RFC 4513 5.1.2).
134	if password.trim().is_empty() {
135		return Err(err!(Request(Forbidden(debug_error!(
136			"LDAP authentication error: empty password"
137		)))));
138	}
139
140	let (driver, mut ldap) = self.ldap_connect(user_dn).await?;
141	let abort = driver.abort_handle();
142	defer! {{ abort.abort(); }};
143
144	ldap.simple_bind(user_dn, password)
145		.await
146		.and_then(ldap3::LdapResult::success)
147		.map_err(|e| {
148			debug!(%e, "LDAP authentication error");
149			err!(Request(Forbidden("Invalid username or password.")))
150		})?;
151
152	ldap.unbind().await.map_err(|e| {
153		error!(%e, "LDAP unbind error");
154		err!(Ldap("LDAP unbind failed"))
155	})?;
156
157	driver.await.log_err().ok();
158
159	Ok(())
160}
161
162#[implement(super::Service)]
163async fn ldap_connect(&self, principal: &str) -> Result<(JoinHandle<()>, Ldap)> {
164	let uri = self
165		.services
166		.config
167		.ldap
168		.uri
169		.as_ref()
170		.ok_or_else(|| err!(Ldap(error!("LDAP URI is not configured."))))?;
171
172	if uri.scheme().starts_with("ldaps") {
173		self.services.globals.init_rustls_provider()?;
174	}
175
176	let settings = LdapConnSettings::new()
177		.set_conn_timeout(CONN_TIMEOUT)
178		.set_no_tls_verify(
179			self.services
180				.config
181				.allow_invalid_tls_certificates,
182		);
183
184	debug!(?uri, "LDAP creating connection...");
185	let (conn, ldap) = LdapConnAsync::from_url_with_settings(settings, uri)
186		.await
187		.map_err(|e| {
188			error!(?principal, %e, "LDAP connection setup error");
189			err!(Ldap("LDAP connection failed"))
190		})?;
191
192	let driver = self.services.server.runtime().spawn(async move {
193		match conn.drive().await {
194			| Err(e) => error!("LDAP connection error: {e}"),
195			| Ok(()) => debug!("LDAP connection completed."),
196		}
197	});
198
199	Ok((driver, ldap))
200}
201
202/// Builds the user bind DN by substituting the escaped localpart into the
203/// configured `bind_dn` template, or `None` when no `{username}` template is
204/// set.
205#[implement(super::Service)]
206#[must_use]
207pub fn ldap_bind_dn(&self, localpart: &str) -> Option<String> {
208	self.services
209		.server
210		.config
211		.ldap
212		.bind_dn
213		.as_ref()
214		.filter(|template| template.contains("{username}"))
215		.map(|template| template.replace("{username}", &dn_escape(localpart)))
216}