]>
Commit | Line | Data |
---|---|---|
6fd77c9a | 1 | use std::{ |
4488256c | 2 | collections::HashMap, |
b9ab0ba4 | 3 | fmt::{Display, Formatter}, |
6fd77c9a LW |
4 | fs, |
5 | path::{Path, PathBuf}, | |
6 | time::Duration, | |
7 | }; | |
8 | ||
7f135263 | 9 | use anyhow::{bail, format_err, Context, Error}; |
4488256c | 10 | use ldap3::adapters::{Adapter, EntriesOnly, PagedResults}; |
b9ab0ba4 | 11 | use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry}; |
6fd77c9a LW |
12 | use native_tls::{Certificate, TlsConnector, TlsConnectorBuilder}; |
13 | use serde::{Deserialize, Serialize}; | |
14 | ||
15 | #[derive(PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Debug)] | |
16 | /// LDAP connection security | |
870be885 | 17 | pub enum ConnectionMode { |
6fd77c9a LW |
18 | /// unencrypted connection |
19 | Ldap, | |
20 | /// upgrade to TLS via STARTTLS | |
21 | StartTls, | |
22 | /// TLS via LDAPS | |
23 | Ldaps, | |
24 | } | |
25 | ||
26 | #[derive(Clone, Serialize, Deserialize)] | |
27 | /// Configuration for LDAP connections | |
870be885 | 28 | pub struct Config { |
6fd77c9a LW |
29 | /// Array of servers that will be tried in order |
30 | pub servers: Vec<String>, | |
31 | /// Port | |
32 | pub port: Option<u16>, | |
33 | /// LDAP attribute containing the user id. Will be used to look up the user's domain | |
34 | pub user_attr: String, | |
35 | /// LDAP base domain | |
36 | pub base_dn: String, | |
37 | /// LDAP bind domain, will be used for user lookup/sync if set | |
38 | pub bind_dn: Option<String>, | |
39 | /// LDAP bind password, will be used for user lookup/sync if set | |
40 | pub bind_password: Option<String>, | |
41 | /// Connection security | |
870be885 | 42 | pub tls_mode: ConnectionMode, |
6fd77c9a LW |
43 | /// Verify the server's TLS certificate |
44 | pub verify_certificate: bool, | |
45 | /// Root certificates that should be trusted, in addition to | |
46 | /// the ones from the certificate store. | |
47 | /// Expects X.509 certs in PEM format. | |
48 | pub additional_trusted_certificates: Option<Vec<PathBuf>>, | |
49 | /// Override the path to the system's default certificate store | |
50 | /// in /etc/ssl/certs (added for PVE compatibility) | |
51 | pub certificate_store_path: Option<PathBuf>, | |
52 | } | |
53 | ||
4488256c LW |
54 | #[derive(Serialize, Deserialize)] |
55 | /// Parameters for LDAP user searches | |
56 | pub struct SearchParameters { | |
57 | /// Attributes that should be retrieved | |
58 | pub attributes: Vec<String>, | |
59 | /// `objectclass`es of intereset | |
60 | pub user_classes: Vec<String>, | |
61 | /// Custom user filter | |
62 | pub user_filter: Option<String>, | |
63 | } | |
64 | ||
582e994c | 65 | #[derive(Serialize, Deserialize, Debug)] |
4488256c LW |
66 | /// Single LDAP user search result |
67 | pub struct SearchResult { | |
68 | /// The full user's domain | |
69 | pub dn: String, | |
70 | /// Queried user attributes | |
71 | pub attributes: HashMap<String, Vec<String>>, | |
72 | } | |
73 | ||
6fd77c9a | 74 | /// Connection to an LDAP server, can be used to authenticate users. |
870be885 | 75 | pub struct Connection { |
6fd77c9a | 76 | /// Configuration for this connection |
870be885 | 77 | config: Config, |
6fd77c9a LW |
78 | } |
79 | ||
870be885 | 80 | impl Connection { |
6fd77c9a LW |
81 | /// Default port for LDAP/StartTls connections |
82 | const LDAP_DEFAULT_PORT: u16 = 389; | |
83 | /// Default port for LDAPS connections | |
84 | const LDAPS_DEFAULT_PORT: u16 = 636; | |
85 | /// Connection timeout | |
86 | const LDAP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); | |
87 | ||
88 | /// Create a new LDAP connection. | |
870be885 | 89 | pub fn new(config: Config) -> Self { |
6fd77c9a LW |
90 | Self { config } |
91 | } | |
92 | ||
93 | /// Authenticate a user with username/password. | |
94 | /// | |
95 | /// The user's domain is queried is by performing an LDAP search with the configured bind_dn | |
96 | /// and bind_password. If no bind_dn is provided, an anonymous search is attempted. | |
97 | pub async fn authenticate_user(&self, username: &str, password: &str) -> Result<(), Error> { | |
98 | let user_dn = self.search_user_dn(username).await?; | |
99 | ||
100 | let mut ldap = self.create_connection().await?; | |
101 | ||
102 | // Perform actual user authentication by binding. | |
103 | let _: LdapResult = ldap.simple_bind(&user_dn, password).await?.success()?; | |
104 | ||
105 | // We are already authenticated, so don't fail if terminating the connection | |
106 | // does not work for some reason. | |
107 | let _: Result<(), _> = ldap.unbind().await; | |
108 | ||
109 | Ok(()) | |
110 | } | |
111 | ||
4488256c LW |
112 | /// Query entities matching given search parameters |
113 | pub async fn search_entities( | |
114 | &self, | |
115 | parameters: &SearchParameters, | |
116 | ) -> Result<Vec<SearchResult>, Error> { | |
117 | let search_filter = Self::assemble_search_filter(parameters); | |
118 | ||
119 | let mut ldap = self.create_connection().await?; | |
120 | ||
121 | if let Some(bind_dn) = self.config.bind_dn.as_deref() { | |
599a6a49 SS |
122 | let password = self |
123 | .config | |
124 | .bind_password | |
125 | .as_deref() | |
126 | .ok_or_else(|| format_err!("Missing bind password for {bind_dn}"))?; | |
4488256c LW |
127 | let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?; |
128 | } | |
129 | ||
130 | let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![ | |
131 | Box::new(EntriesOnly::new()), | |
132 | Box::new(PagedResults::new(500)), | |
133 | ]; | |
134 | let mut search = ldap | |
135 | .streaming_search_with( | |
136 | adapters, | |
137 | &self.config.base_dn, | |
138 | Scope::Subtree, | |
139 | &search_filter, | |
140 | parameters.attributes.clone(), | |
141 | ) | |
142 | .await?; | |
143 | ||
144 | let mut results = Vec::new(); | |
145 | ||
146 | while let Some(entry) = search.next().await? { | |
147 | let entry = SearchEntry::construct(entry); | |
148 | ||
149 | results.push(SearchResult { | |
150 | dn: entry.dn, | |
151 | attributes: entry.attrs, | |
152 | }) | |
153 | } | |
154 | let _res = search.finish().await.success()?; | |
155 | ||
156 | let _ = ldap.unbind().await; | |
157 | ||
158 | Ok(results) | |
159 | } | |
160 | ||
7f135263 SS |
161 | /// Helper to check if a connection with the current configuration is possible. |
162 | /// | |
163 | /// This performs a search with the current configuration. If the search succeeds `Ok(()) is | |
164 | /// returned, otherwise an `Error` is returned. | |
165 | pub async fn check_connection(&self) -> Result<(), Error> { | |
166 | let mut ldap = self.create_connection().await?; | |
167 | ||
168 | if let Some(bind_dn) = self.config.bind_dn.as_deref() { | |
169 | let password = self | |
170 | .config | |
171 | .bind_password | |
172 | .as_deref() | |
173 | .ok_or_else(|| format_err!("Missing bind password for {bind_dn}"))?; | |
174 | ||
175 | let _: LdapResult = ldap | |
176 | .simple_bind(bind_dn, password) | |
177 | .await? | |
178 | .success() | |
179 | .context("LDAP bind failed, bind_dn or password could be incorrect")?; | |
c74167f5 | 180 | } |
7f135263 | 181 | |
c74167f5 SS |
182 | // only search base to make sure the base_dn exists while avoiding most common size limits |
183 | let (_, _) = ldap | |
84fbfb22 | 184 | .search(&self.config.base_dn, Scope::Base, "(objectClass=*)", &["*"]) |
c74167f5 SS |
185 | .await? |
186 | .success() | |
187 | .context("Could not search LDAP realm, base_dn could be incorrect")?; | |
7f135263 | 188 | |
c74167f5 | 189 | if self.config.bind_dn.is_some() { |
7f135263 | 190 | let _: Result<(), _> = ldap.unbind().await; // ignore errors, search succeeded already |
7f135263 SS |
191 | } |
192 | ||
193 | Ok(()) | |
194 | } | |
195 | ||
72afba8b CH |
196 | /// Retrieves an attribute from the root DSE according to RFC 4512, Section 5.1 |
197 | /// https://www.rfc-editor.org/rfc/rfc4512#section-5.1 | |
198 | pub async fn retrieve_root_dse_attr(&self, attr: &str) -> Result<Vec<String>, Error> { | |
199 | let mut ldap = self.create_connection().await?; | |
200 | ||
201 | let (entries, _res) = ldap | |
202 | .search("", Scope::Base, "(objectClass=*)", &[attr]) | |
203 | .await? | |
204 | .success()?; | |
205 | ||
206 | if entries.len() > 1 { | |
207 | bail!("found multiple root DSEs with attribute '{attr}'"); | |
208 | } | |
209 | ||
210 | entries | |
211 | .into_iter() | |
212 | .next() | |
213 | .map(SearchEntry::construct) | |
214 | .and_then(|e| e.attrs.get(attr).cloned()) | |
215 | .ok_or_else(|| format_err!("failed to retrieve root DSE attribute '{attr}'")) | |
216 | } | |
217 | ||
6fd77c9a LW |
218 | /// Retrive port from LDAP configuration, otherwise use the correct default |
219 | fn port_from_config(&self) -> u16 { | |
220 | self.config.port.unwrap_or_else(|| { | |
870be885 | 221 | if self.config.tls_mode == ConnectionMode::Ldaps { |
6fd77c9a LW |
222 | Self::LDAPS_DEFAULT_PORT |
223 | } else { | |
224 | Self::LDAP_DEFAULT_PORT | |
225 | } | |
226 | }) | |
227 | } | |
228 | ||
229 | /// Determine correct URL scheme from LDAP config | |
230 | fn scheme_from_config(&self) -> &'static str { | |
870be885 | 231 | if self.config.tls_mode == ConnectionMode::Ldaps { |
6fd77c9a LW |
232 | "ldaps" |
233 | } else { | |
234 | "ldap" | |
235 | } | |
236 | } | |
237 | ||
238 | /// Construct URL from LDAP config | |
239 | fn ldap_url_from_config(&self, server: &str) -> String { | |
240 | let port = self.port_from_config(); | |
241 | let scheme = self.scheme_from_config(); | |
242 | format!("{scheme}://{server}:{port}") | |
243 | } | |
244 | ||
245 | fn add_cert_to_builder<P: AsRef<Path>>( | |
246 | path: P, | |
247 | builder: &mut TlsConnectorBuilder, | |
248 | ) -> Result<(), Error> { | |
249 | let bytes = fs::read(path)?; | |
250 | let cert = Certificate::from_pem(&bytes)?; | |
251 | builder.add_root_certificate(cert); | |
252 | ||
253 | Ok(()) | |
254 | } | |
255 | ||
256 | async fn try_connect(&self, url: &str) -> Result<(LdapConnAsync, Ldap), Error> { | |
870be885 | 257 | let starttls = self.config.tls_mode == ConnectionMode::StartTls; |
6fd77c9a LW |
258 | |
259 | let mut builder = TlsConnector::builder(); | |
260 | builder.danger_accept_invalid_certs(!self.config.verify_certificate); | |
261 | ||
262 | if let Some(certificate_paths) = self.config.additional_trusted_certificates.as_deref() { | |
263 | for path in certificate_paths { | |
264 | Self::add_cert_to_builder(path, &mut builder)?; | |
265 | } | |
266 | } | |
267 | ||
268 | if let Some(certificate_store_path) = self.config.certificate_store_path.as_deref() { | |
269 | builder.disable_built_in_roots(true); | |
270 | ||
271 | for dir_entry in fs::read_dir(certificate_store_path)? { | |
272 | let dir_entry = dir_entry?; | |
273 | ||
274 | if !dir_entry.metadata()?.is_dir() { | |
275 | Self::add_cert_to_builder(dir_entry.path(), &mut builder)?; | |
276 | } | |
277 | } | |
278 | } | |
279 | ||
280 | LdapConnAsync::with_settings( | |
281 | LdapConnSettings::new() | |
282 | .set_starttls(starttls) | |
283 | .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT) | |
284 | .set_connector(builder.build()?), | |
285 | url, | |
286 | ) | |
287 | .await | |
288 | .map_err(|e| e.into()) | |
289 | } | |
290 | ||
291 | /// Create LDAP connection | |
292 | /// | |
293 | /// If a connection to the server cannot be established, the fallbacks | |
294 | /// are tried. | |
295 | async fn create_connection(&self) -> Result<Ldap, Error> { | |
296 | let mut last_error = None; | |
297 | ||
298 | for server in &self.config.servers { | |
299 | match self.try_connect(&self.ldap_url_from_config(server)).await { | |
300 | Ok((connection, ldap)) => { | |
301 | ldap3::drive!(connection); | |
302 | return Ok(ldap); | |
303 | } | |
304 | Err(e) => { | |
305 | last_error = Some(e); | |
306 | } | |
307 | } | |
308 | } | |
309 | ||
310 | Err(last_error.unwrap()) | |
311 | } | |
312 | ||
313 | /// Search a user's domain. | |
314 | async fn search_user_dn(&self, username: &str) -> Result<String, Error> { | |
315 | let mut ldap = self.create_connection().await?; | |
316 | ||
317 | if let Some(bind_dn) = self.config.bind_dn.as_deref() { | |
599a6a49 SS |
318 | let password = self |
319 | .config | |
320 | .bind_password | |
321 | .as_deref() | |
322 | .ok_or_else(|| format_err!("Missing bind password for {bind_dn}"))?; | |
6fd77c9a LW |
323 | let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?; |
324 | ||
325 | let user_dn = self.do_search_user_dn(username, &mut ldap).await; | |
326 | ||
327 | ldap.unbind().await?; | |
328 | ||
329 | user_dn | |
330 | } else { | |
331 | self.do_search_user_dn(username, &mut ldap).await | |
332 | } | |
333 | } | |
334 | ||
335 | async fn do_search_user_dn(&self, username: &str, ldap: &mut Ldap) -> Result<String, Error> { | |
336 | let query = format!("(&({}={}))", self.config.user_attr, username); | |
337 | ||
338 | let (entries, _res) = ldap | |
84fbfb22 | 339 | .search(&self.config.base_dn, Scope::Subtree, &query, &["dn"]) |
6fd77c9a LW |
340 | .await? |
341 | .success()?; | |
342 | ||
343 | if entries.len() > 1 { | |
344 | bail!( | |
345 | "found multiple users with attribute `{}={}`", | |
346 | self.config.user_attr, | |
347 | username | |
348 | ) | |
349 | } | |
350 | ||
351 | if let Some(entry) = entries.into_iter().next() { | |
352 | let entry = SearchEntry::construct(entry); | |
353 | ||
354 | return Ok(entry.dn); | |
355 | } | |
356 | ||
357 | bail!("user not found") | |
358 | } | |
4488256c LW |
359 | |
360 | fn assemble_search_filter(parameters: &SearchParameters) -> String { | |
361 | use FilterElement::*; | |
362 | ||
363 | let attr_wildcards = Or(parameters | |
364 | .attributes | |
365 | .iter() | |
366 | .map(|attr| Condition(attr, "*")) | |
367 | .collect()); | |
368 | let user_classes = Or(parameters | |
369 | .user_classes | |
370 | .iter() | |
cd61c874 | 371 | .map(|class| Condition("objectclass", class)) |
4488256c LW |
372 | .collect()); |
373 | ||
374 | if let Some(user_filter) = ¶meters.user_filter { | |
375 | And(vec![Verbatim(user_filter), attr_wildcards, user_classes]) | |
376 | } else { | |
377 | And(vec![attr_wildcards, user_classes]) | |
378 | } | |
379 | .to_string() | |
380 | } | |
6fd77c9a | 381 | } |
b9ab0ba4 LW |
382 | |
383 | #[allow(dead_code)] | |
384 | enum FilterElement<'a> { | |
385 | And(Vec<FilterElement<'a>>), | |
386 | Or(Vec<FilterElement<'a>>), | |
387 | Condition(&'a str, &'a str), | |
388 | Not(Box<FilterElement<'a>>), | |
389 | Verbatim(&'a str), | |
390 | } | |
391 | ||
392 | impl<'a> Display for FilterElement<'a> { | |
393 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { | |
394 | fn write_children(f: &mut Formatter<'_>, children: &[FilterElement]) -> std::fmt::Result { | |
395 | for child in children { | |
396 | write!(f, "{child}")?; | |
397 | } | |
398 | ||
399 | Ok(()) | |
400 | } | |
401 | ||
402 | match self { | |
403 | FilterElement::And(children) => { | |
404 | write!(f, "(&")?; | |
405 | write_children(f, children)?; | |
406 | write!(f, ")")?; | |
407 | } | |
408 | FilterElement::Or(children) => { | |
409 | write!(f, "(|")?; | |
410 | write_children(f, children)?; | |
411 | write!(f, ")")?; | |
412 | } | |
413 | FilterElement::Not(element) => { | |
cd61c874 | 414 | write!(f, "(!{element})")?; |
b9ab0ba4 LW |
415 | } |
416 | FilterElement::Condition(attr, value) => { | |
417 | write!(f, "({attr}={value})")?; | |
418 | } | |
378e2380 | 419 | FilterElement::Verbatim(verbatim) => { |
378e2380 LW |
420 | if !verbatim.starts_with('(') && !verbatim.ends_with(')') { |
421 | write!(f, "({verbatim})")? | |
422 | } else { | |
423 | write!(f, "{verbatim}")? | |
424 | } | |
5791af8f | 425 | } |
b9ab0ba4 LW |
426 | } |
427 | ||
428 | Ok(()) | |
429 | } | |
430 | } | |
431 | ||
432 | #[cfg(test)] | |
433 | mod tests { | |
434 | use super::FilterElement::*; | |
435 | ||
436 | #[test] | |
437 | fn test_filter_elements_to_string() { | |
438 | assert_eq!("(uid=john)", Condition("uid", "john").to_string()); | |
439 | assert_eq!( | |
440 | "(!(uid=john))", | |
441 | Not(Box::new(Condition("uid", "john"))).to_string() | |
442 | ); | |
443 | ||
444 | assert_eq!("(foo=bar)", &Verbatim("(foo=bar)").to_string()); | |
378e2380 | 445 | assert_eq!("(foo=bar)", &Verbatim("foo=bar").to_string()); |
b9ab0ba4 LW |
446 | |
447 | let filter_string = And(vec![ | |
448 | Condition("givenname", "john"), | |
449 | Condition("sn", "doe"), | |
450 | Or(vec![ | |
451 | Condition("email", "john@foo"), | |
452 | Condition("email", "john@bar"), | |
453 | ]), | |
454 | ]) | |
455 | .to_string(); | |
456 | ||
457 | assert_eq!( | |
458 | "(&(givenname=john)(sn=doe)(|(email=john@foo)(email=john@bar)))", | |
459 | &filter_string | |
460 | ); | |
461 | } | |
462 | } |