]>
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 | ||
9 | use anyhow::{bail, 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 | |
17 | pub 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 | |
28 | pub 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 | |
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 LW |
74 | /// Connection to an LDAP server, can be used to authenticate users. |
75 | pub struct LdapConnection { | |
76 | /// Configuration for this connection | |
77 | config: LdapConfig, | |
78 | } | |
79 | ||
80 | impl 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) = ¶meters.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)] | |
319 | enum 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 | ||
327 | impl<'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)] | |
362 | mod 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 | } |