]> git.proxmox.com Git - proxmox.git/blob - proxmox-ldap/src/lib.rs
b3b5d65f70167d5d09ba1989a19a662d76f6402e
[proxmox.git] / proxmox-ldap / src / lib.rs
1 use std::{
2 collections::HashMap,
3 fmt::{Display, Formatter},
4 fs,
5 path::{Path, PathBuf},
6 time::Duration,
7 };
8
9 use anyhow::{bail, format_err, Context, Error};
10 use ldap3::adapters::{Adapter, EntriesOnly, PagedResults};
11 use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry};
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
17 pub enum ConnectionMode {
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
28 pub struct Config {
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
42 pub tls_mode: ConnectionMode,
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
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
65 #[derive(Serialize, Deserialize, Debug)]
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
74 /// Connection to an LDAP server, can be used to authenticate users.
75 pub struct Connection {
76 /// Configuration for this connection
77 config: Config,
78 }
79
80 impl Connection {
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.
89 pub fn new(config: Config) -> Self {
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
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() {
122 let password = self
123 .config
124 .bind_password
125 .as_deref()
126 .ok_or_else(|| format_err!("Missing bind password for {bind_dn}"))?;
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
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")?;
180 }
181
182 // only search base to make sure the base_dn exists while avoiding most common size limits
183 let (_, _) = ldap
184 .search(
185 &self.config.base_dn,
186 Scope::Base,
187 "(objectClass=*)",
188 vec!["*"],
189 )
190 .await?
191 .success()
192 .context("Could not search LDAP realm, base_dn could be incorrect")?;
193
194 if self.config.bind_dn.is_some() {
195 let _: Result<(), _> = ldap.unbind().await; // ignore errors, search succeeded already
196 }
197
198 Ok(())
199 }
200
201 /// Retrive port from LDAP configuration, otherwise use the correct default
202 fn port_from_config(&self) -> u16 {
203 self.config.port.unwrap_or_else(|| {
204 if self.config.tls_mode == ConnectionMode::Ldaps {
205 Self::LDAPS_DEFAULT_PORT
206 } else {
207 Self::LDAP_DEFAULT_PORT
208 }
209 })
210 }
211
212 /// Determine correct URL scheme from LDAP config
213 fn scheme_from_config(&self) -> &'static str {
214 if self.config.tls_mode == ConnectionMode::Ldaps {
215 "ldaps"
216 } else {
217 "ldap"
218 }
219 }
220
221 /// Construct URL from LDAP config
222 fn ldap_url_from_config(&self, server: &str) -> String {
223 let port = self.port_from_config();
224 let scheme = self.scheme_from_config();
225 format!("{scheme}://{server}:{port}")
226 }
227
228 fn add_cert_to_builder<P: AsRef<Path>>(
229 path: P,
230 builder: &mut TlsConnectorBuilder,
231 ) -> Result<(), Error> {
232 let bytes = fs::read(path)?;
233 let cert = Certificate::from_pem(&bytes)?;
234 builder.add_root_certificate(cert);
235
236 Ok(())
237 }
238
239 async fn try_connect(&self, url: &str) -> Result<(LdapConnAsync, Ldap), Error> {
240 let starttls = self.config.tls_mode == ConnectionMode::StartTls;
241
242 let mut builder = TlsConnector::builder();
243 builder.danger_accept_invalid_certs(!self.config.verify_certificate);
244
245 if let Some(certificate_paths) = self.config.additional_trusted_certificates.as_deref() {
246 for path in certificate_paths {
247 Self::add_cert_to_builder(path, &mut builder)?;
248 }
249 }
250
251 if let Some(certificate_store_path) = self.config.certificate_store_path.as_deref() {
252 builder.disable_built_in_roots(true);
253
254 for dir_entry in fs::read_dir(certificate_store_path)? {
255 let dir_entry = dir_entry?;
256
257 if !dir_entry.metadata()?.is_dir() {
258 Self::add_cert_to_builder(dir_entry.path(), &mut builder)?;
259 }
260 }
261 }
262
263 LdapConnAsync::with_settings(
264 LdapConnSettings::new()
265 .set_starttls(starttls)
266 .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT)
267 .set_connector(builder.build()?),
268 url,
269 )
270 .await
271 .map_err(|e| e.into())
272 }
273
274 /// Create LDAP connection
275 ///
276 /// If a connection to the server cannot be established, the fallbacks
277 /// are tried.
278 async fn create_connection(&self) -> Result<Ldap, Error> {
279 let mut last_error = None;
280
281 for server in &self.config.servers {
282 match self.try_connect(&self.ldap_url_from_config(server)).await {
283 Ok((connection, ldap)) => {
284 ldap3::drive!(connection);
285 return Ok(ldap);
286 }
287 Err(e) => {
288 last_error = Some(e);
289 }
290 }
291 }
292
293 Err(last_error.unwrap())
294 }
295
296 /// Search a user's domain.
297 async fn search_user_dn(&self, username: &str) -> Result<String, Error> {
298 let mut ldap = self.create_connection().await?;
299
300 if let Some(bind_dn) = self.config.bind_dn.as_deref() {
301 let password = self
302 .config
303 .bind_password
304 .as_deref()
305 .ok_or_else(|| format_err!("Missing bind password for {bind_dn}"))?;
306 let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?;
307
308 let user_dn = self.do_search_user_dn(username, &mut ldap).await;
309
310 ldap.unbind().await?;
311
312 user_dn
313 } else {
314 self.do_search_user_dn(username, &mut ldap).await
315 }
316 }
317
318 async fn do_search_user_dn(&self, username: &str, ldap: &mut Ldap) -> Result<String, Error> {
319 let query = format!("(&({}={}))", self.config.user_attr, username);
320
321 let (entries, _res) = ldap
322 .search(&self.config.base_dn, Scope::Subtree, &query, vec!["dn"])
323 .await?
324 .success()?;
325
326 if entries.len() > 1 {
327 bail!(
328 "found multiple users with attribute `{}={}`",
329 self.config.user_attr,
330 username
331 )
332 }
333
334 if let Some(entry) = entries.into_iter().next() {
335 let entry = SearchEntry::construct(entry);
336
337 return Ok(entry.dn);
338 }
339
340 bail!("user not found")
341 }
342
343 fn assemble_search_filter(parameters: &SearchParameters) -> String {
344 use FilterElement::*;
345
346 let attr_wildcards = Or(parameters
347 .attributes
348 .iter()
349 .map(|attr| Condition(attr, "*"))
350 .collect());
351 let user_classes = Or(parameters
352 .user_classes
353 .iter()
354 .map(|class| Condition("objectclass", class))
355 .collect());
356
357 if let Some(user_filter) = &parameters.user_filter {
358 And(vec![Verbatim(user_filter), attr_wildcards, user_classes])
359 } else {
360 And(vec![attr_wildcards, user_classes])
361 }
362 .to_string()
363 }
364 }
365
366 #[allow(dead_code)]
367 enum FilterElement<'a> {
368 And(Vec<FilterElement<'a>>),
369 Or(Vec<FilterElement<'a>>),
370 Condition(&'a str, &'a str),
371 Not(Box<FilterElement<'a>>),
372 Verbatim(&'a str),
373 }
374
375 impl<'a> Display for FilterElement<'a> {
376 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
377 fn write_children(f: &mut Formatter<'_>, children: &[FilterElement]) -> std::fmt::Result {
378 for child in children {
379 write!(f, "{child}")?;
380 }
381
382 Ok(())
383 }
384
385 match self {
386 FilterElement::And(children) => {
387 write!(f, "(&")?;
388 write_children(f, children)?;
389 write!(f, ")")?;
390 }
391 FilterElement::Or(children) => {
392 write!(f, "(|")?;
393 write_children(f, children)?;
394 write!(f, ")")?;
395 }
396 FilterElement::Not(element) => {
397 write!(f, "(!{element})")?;
398 }
399 FilterElement::Condition(attr, value) => {
400 write!(f, "({attr}={value})")?;
401 }
402 FilterElement::Verbatim(verbatim) => {
403 if !verbatim.starts_with('(') && !verbatim.ends_with(')') {
404 write!(f, "({verbatim})")?
405 } else {
406 write!(f, "{verbatim}")?
407 }
408 }
409 }
410
411 Ok(())
412 }
413 }
414
415 #[cfg(test)]
416 mod tests {
417 use super::FilterElement::*;
418
419 #[test]
420 fn test_filter_elements_to_string() {
421 assert_eq!("(uid=john)", Condition("uid", "john").to_string());
422 assert_eq!(
423 "(!(uid=john))",
424 Not(Box::new(Condition("uid", "john"))).to_string()
425 );
426
427 assert_eq!("(foo=bar)", &Verbatim("(foo=bar)").to_string());
428 assert_eq!("(foo=bar)", &Verbatim("foo=bar").to_string());
429
430 let filter_string = And(vec![
431 Condition("givenname", "john"),
432 Condition("sn", "doe"),
433 Or(vec![
434 Condition("email", "john@foo"),
435 Condition("email", "john@bar"),
436 ]),
437 ])
438 .to_string();
439
440 assert_eq!(
441 "(&(givenname=john)(sn=doe)(|(email=john@foo)(email=john@bar)))",
442 &filter_string
443 );
444 }
445 }