]> git.proxmox.com Git - proxmox.git/blame - proxmox-ldap/src/lib.rs
fix 'default-features = false' for ldap3
[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
9use anyhow::{bail, 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
17pub enum LdapConnectionMode {
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
28pub struct LdapConfig {
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: LdapConnectionMode,
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
LW
74/// Connection to an LDAP server, can be used to authenticate users.
75pub struct LdapConnection {
76 /// Configuration for this connection
77 config: LdapConfig,
78}
79
80impl LdapConnection {
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: LdapConfig) -> 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
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() {
122 let password = self.config.bind_password.as_deref().unwrap_or_default();
123 let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?;
124 }
125
126 let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
127 Box::new(EntriesOnly::new()),
128 Box::new(PagedResults::new(500)),
129 ];
130 let mut search = ldap
131 .streaming_search_with(
132 adapters,
133 &self.config.base_dn,
134 Scope::Subtree,
135 &search_filter,
136 parameters.attributes.clone(),
137 )
138 .await?;
139
140 let mut results = Vec::new();
141
142 while let Some(entry) = search.next().await? {
143 let entry = SearchEntry::construct(entry);
144
145 results.push(SearchResult {
146 dn: entry.dn,
147 attributes: entry.attrs,
148 })
149 }
150 let _res = search.finish().await.success()?;
151
152 let _ = ldap.unbind().await;
153
154 Ok(results)
155 }
156
6fd77c9a
LW
157 /// Retrive port from LDAP configuration, otherwise use the correct default
158 fn port_from_config(&self) -> u16 {
159 self.config.port.unwrap_or_else(|| {
160 if self.config.tls_mode == LdapConnectionMode::Ldaps {
161 Self::LDAPS_DEFAULT_PORT
162 } else {
163 Self::LDAP_DEFAULT_PORT
164 }
165 })
166 }
167
168 /// Determine correct URL scheme from LDAP config
169 fn scheme_from_config(&self) -> &'static str {
170 if self.config.tls_mode == LdapConnectionMode::Ldaps {
171 "ldaps"
172 } else {
173 "ldap"
174 }
175 }
176
177 /// Construct URL from LDAP config
178 fn ldap_url_from_config(&self, server: &str) -> String {
179 let port = self.port_from_config();
180 let scheme = self.scheme_from_config();
181 format!("{scheme}://{server}:{port}")
182 }
183
184 fn add_cert_to_builder<P: AsRef<Path>>(
185 path: P,
186 builder: &mut TlsConnectorBuilder,
187 ) -> Result<(), Error> {
188 let bytes = fs::read(path)?;
189 let cert = Certificate::from_pem(&bytes)?;
190 builder.add_root_certificate(cert);
191
192 Ok(())
193 }
194
195 async fn try_connect(&self, url: &str) -> Result<(LdapConnAsync, Ldap), Error> {
196 let starttls = self.config.tls_mode == LdapConnectionMode::StartTls;
197
198 let mut builder = TlsConnector::builder();
199 builder.danger_accept_invalid_certs(!self.config.verify_certificate);
200
201 if let Some(certificate_paths) = self.config.additional_trusted_certificates.as_deref() {
202 for path in certificate_paths {
203 Self::add_cert_to_builder(path, &mut builder)?;
204 }
205 }
206
207 if let Some(certificate_store_path) = self.config.certificate_store_path.as_deref() {
208 builder.disable_built_in_roots(true);
209
210 for dir_entry in fs::read_dir(certificate_store_path)? {
211 let dir_entry = dir_entry?;
212
213 if !dir_entry.metadata()?.is_dir() {
214 Self::add_cert_to_builder(dir_entry.path(), &mut builder)?;
215 }
216 }
217 }
218
219 LdapConnAsync::with_settings(
220 LdapConnSettings::new()
221 .set_starttls(starttls)
222 .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT)
223 .set_connector(builder.build()?),
224 url,
225 )
226 .await
227 .map_err(|e| e.into())
228 }
229
230 /// Create LDAP connection
231 ///
232 /// If a connection to the server cannot be established, the fallbacks
233 /// are tried.
234 async fn create_connection(&self) -> Result<Ldap, Error> {
235 let mut last_error = None;
236
237 for server in &self.config.servers {
238 match self.try_connect(&self.ldap_url_from_config(server)).await {
239 Ok((connection, ldap)) => {
240 ldap3::drive!(connection);
241 return Ok(ldap);
242 }
243 Err(e) => {
244 last_error = Some(e);
245 }
246 }
247 }
248
249 Err(last_error.unwrap())
250 }
251
252 /// Search a user's domain.
253 async fn search_user_dn(&self, username: &str) -> Result<String, Error> {
254 let mut ldap = self.create_connection().await?;
255
256 if let Some(bind_dn) = self.config.bind_dn.as_deref() {
257 let password = self.config.bind_password.as_deref().unwrap_or_default();
258 let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?;
259
260 let user_dn = self.do_search_user_dn(username, &mut ldap).await;
261
262 ldap.unbind().await?;
263
264 user_dn
265 } else {
266 self.do_search_user_dn(username, &mut ldap).await
267 }
268 }
269
270 async fn do_search_user_dn(&self, username: &str, ldap: &mut Ldap) -> Result<String, Error> {
271 let query = format!("(&({}={}))", self.config.user_attr, username);
272
273 let (entries, _res) = ldap
274 .search(&self.config.base_dn, Scope::Subtree, &query, vec!["dn"])
275 .await?
276 .success()?;
277
278 if entries.len() > 1 {
279 bail!(
280 "found multiple users with attribute `{}={}`",
281 self.config.user_attr,
282 username
283 )
284 }
285
286 if let Some(entry) = entries.into_iter().next() {
287 let entry = SearchEntry::construct(entry);
288
289 return Ok(entry.dn);
290 }
291
292 bail!("user not found")
293 }
4488256c
LW
294
295 fn assemble_search_filter(parameters: &SearchParameters) -> String {
296 use FilterElement::*;
297
298 let attr_wildcards = Or(parameters
299 .attributes
300 .iter()
301 .map(|attr| Condition(attr, "*"))
302 .collect());
303 let user_classes = Or(parameters
304 .user_classes
305 .iter()
cd61c874 306 .map(|class| Condition("objectclass", class))
4488256c
LW
307 .collect());
308
309 if let Some(user_filter) = &parameters.user_filter {
310 And(vec![Verbatim(user_filter), attr_wildcards, user_classes])
311 } else {
312 And(vec![attr_wildcards, user_classes])
313 }
314 .to_string()
315 }
6fd77c9a 316}
b9ab0ba4
LW
317
318#[allow(dead_code)]
319enum FilterElement<'a> {
320 And(Vec<FilterElement<'a>>),
321 Or(Vec<FilterElement<'a>>),
322 Condition(&'a str, &'a str),
323 Not(Box<FilterElement<'a>>),
324 Verbatim(&'a str),
325}
326
327impl<'a> Display for FilterElement<'a> {
328 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
329 fn write_children(f: &mut Formatter<'_>, children: &[FilterElement]) -> std::fmt::Result {
330 for child in children {
331 write!(f, "{child}")?;
332 }
333
334 Ok(())
335 }
336
337 match self {
338 FilterElement::And(children) => {
339 write!(f, "(&")?;
340 write_children(f, children)?;
341 write!(f, ")")?;
342 }
343 FilterElement::Or(children) => {
344 write!(f, "(|")?;
345 write_children(f, children)?;
346 write!(f, ")")?;
347 }
348 FilterElement::Not(element) => {
cd61c874 349 write!(f, "(!{element})")?;
b9ab0ba4
LW
350 }
351 FilterElement::Condition(attr, value) => {
352 write!(f, "({attr}={value})")?;
353 }
354 FilterElement::Verbatim(verbatim) => write!(f, "{verbatim}")?,
355 }
356
357 Ok(())
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::FilterElement::*;
364
365 #[test]
366 fn test_filter_elements_to_string() {
367 assert_eq!("(uid=john)", Condition("uid", "john").to_string());
368 assert_eq!(
369 "(!(uid=john))",
370 Not(Box::new(Condition("uid", "john"))).to_string()
371 );
372
373 assert_eq!("(foo=bar)", &Verbatim("(foo=bar)").to_string());
374
375 let filter_string = And(vec![
376 Condition("givenname", "john"),
377 Condition("sn", "doe"),
378 Or(vec![
379 Condition("email", "john@foo"),
380 Condition("email", "john@bar"),
381 ]),
382 ])
383 .to_string();
384
385 assert_eq!(
386 "(&(givenname=john)(sn=doe)(|(email=john@foo)(email=john@bar)))",
387 &filter_string
388 );
389 }
390}