]> git.proxmox.com Git - proxmox.git/blame - proxmox-ldap/src/lib.rs
ldap: add method for retrieving root DSE attributes
[proxmox.git] / proxmox-ldap / src / lib.rs
CommitLineData
6fd77c9a 1use std::{
4488256c 2 collections::HashMap,
b9ab0ba4 3 fmt::{Display, Formatter},
6fd77c9a
LW
4 fs,
5 path::{Path, PathBuf},
6 time::Duration,
7};
8
7f135263 9use anyhow::{bail, format_err, Context, Error};
4488256c 10use ldap3::adapters::{Adapter, EntriesOnly, PagedResults};
b9ab0ba4 11use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry};
6fd77c9a
LW
12use native_tls::{Certificate, TlsConnector, TlsConnectorBuilder};
13use serde::{Deserialize, Serialize};
14
15#[derive(PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Debug)]
16/// LDAP connection security
870be885 17pub 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 28pub 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
56pub 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
67pub 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 75pub struct Connection {
6fd77c9a 76 /// Configuration for this connection
870be885 77 config: Config,
6fd77c9a
LW
78}
79
870be885 80impl 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) = &parameters.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)]
384enum 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
392impl<'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)]
433mod 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}