1use crate::connection::{LdapConfig, LdapConnection};
44use crate::protocol::{
45 Attribute, Filter, LdapResult, ResultCode, Scope, SearchEntry, SearchRequest,
46};
47use rustlavel_auth::Authenticatable;
48use rustlavel_core::{Error, Result};
49use std::time::Duration;
50
51enum ServiceIdentity {
55 Anonymous,
59 Simple { dn: String, password: String },
60}
61
62pub struct Directory {
66 config: LdapConfig,
67 service: ServiceIdentity,
68 base_dn: String,
69 username_attribute: String,
70 scope: Scope,
71 extra_filter: Option<Filter>,
72 attributes: Vec<String>,
73 dn_template: Option<String>,
74}
75
76impl Directory {
77 pub fn new(url: &str) -> Result<Directory> {
79 Ok(Directory::from_config(LdapConfig::parse(url)?))
80 }
81
82 pub fn from_config(config: LdapConfig) -> Directory {
83 Directory {
84 config,
85 service: ServiceIdentity::Anonymous,
86 base_dn: String::new(),
87 username_attribute: "uid".to_string(),
90 scope: Scope::Subtree,
91 extra_filter: None,
92 attributes: Vec::new(),
93 dn_template: None,
94 }
95 }
96
97 pub fn service_account(
99 mut self,
100 dn: impl Into<String>,
101 password: impl Into<String>,
102 ) -> Directory {
103 self.service = ServiceIdentity::Simple { dn: dn.into(), password: password.into() };
104 self
105 }
106
107 pub fn anonymous_search(mut self) -> Directory {
109 self.service = ServiceIdentity::Anonymous;
110 self
111 }
112
113 pub fn base_dn(mut self, dn: impl Into<String>) -> Directory {
115 self.base_dn = dn.into();
116 self
117 }
118
119 pub fn username_attribute(mut self, attribute: impl Into<String>) -> Directory {
122 self.username_attribute = attribute.into();
123 self
124 }
125
126 pub fn scope(mut self, scope: Scope) -> Directory {
127 self.scope = scope;
128 self
129 }
130
131 pub fn filter(mut self, filter: Filter) -> Directory {
137 self.extra_filter = Some(filter);
138 self
139 }
140
141 pub fn attributes<S: Into<String>>(
144 mut self,
145 attributes: impl IntoIterator<Item = S>,
146 ) -> Directory {
147 self.attributes = attributes.into_iter().map(Into::into).collect();
148 self
149 }
150
151 pub fn user_dn_template(mut self, template: impl Into<String>) -> Directory {
163 self.dn_template = Some(template.into());
164 self
165 }
166
167 pub fn timeout(mut self, timeout: Duration) -> Directory {
168 self.config.timeout = timeout;
169 self
170 }
171
172 pub fn connect_timeout(mut self, timeout: Duration) -> Directory {
173 self.config.connect_timeout = timeout;
174 self
175 }
176
177 pub fn allow_plaintext_password(mut self) -> Directory {
180 self.config.allow_plaintext_password = true;
181 self
182 }
183
184 pub fn dangerously_accept_any_certificate(mut self) -> Directory {
186 self.config.verify_certificate = false;
187 self
188 }
189
190 pub fn config(&self) -> &LdapConfig {
191 &self.config
192 }
193
194 pub async fn authenticate(
201 &self,
202 username: &str,
203 password: &str,
204 ) -> std::result::Result<User, AuthenticationError> {
205 if password.is_empty() {
212 return Err(AuthenticationError::EmptyPassword);
213 }
214 if username.is_empty() {
215 return Err(AuthenticationError::EmptyUsername);
216 }
217 if username.contains('\0') {
220 return Err(AuthenticationError::EmptyUsername);
221 }
222
223 match &self.dn_template {
224 Some(template) => self.authenticate_by_template(template, username, password).await,
225 None => self.authenticate_by_search(username, password).await,
226 }
227 }
228
229 async fn authenticate_by_search(
230 &self,
231 username: &str,
232 password: &str,
233 ) -> std::result::Result<User, AuthenticationError> {
234 let entry = self.find(username).await?;
235
236 let mut connection = self.connect().await?;
243 let result = connection
244 .bind(&entry.dn, password)
245 .await
246 .map_err(AuthenticationError::Unreachable)?;
247 let _ = connection.unbind().await;
248
249 classify(result)?;
250 Ok(User::from(entry))
251 }
252
253 async fn authenticate_by_template(
254 &self,
255 template: &str,
256 username: &str,
257 password: &str,
258 ) -> std::result::Result<User, AuthenticationError> {
259 let dn = template.replace("{username}", &crate::protocol::escape_dn_value(username));
262
263 let mut connection = self.connect().await?;
264 let result =
265 connection.bind(&dn, password).await.map_err(AuthenticationError::Unreachable)?;
266 let _ = connection.unbind().await;
267
268 classify(result)?;
269 Ok(User { dn, attributes: Vec::new() })
270 }
271
272 pub async fn find(
278 &self,
279 username: &str,
280 ) -> std::result::Result<SearchEntry, AuthenticationError> {
281 if username.is_empty() || username.contains('\0') {
282 return Err(AuthenticationError::EmptyUsername);
283 }
284
285 let mut connection = self.connect().await?;
286
287 match &self.service {
292 ServiceIdentity::Anonymous => connection
293 .bind_anonymous()
294 .await
295 .map_err(AuthenticationError::ServiceAccount)?
296 .into_result("anonymous bind")
297 .map_err(AuthenticationError::ServiceAccount)?,
298 ServiceIdentity::Simple { dn, password } => connection
299 .bind(dn, password)
300 .await
301 .map_err(AuthenticationError::ServiceAccount)?
302 .into_result("service account bind")
303 .map_err(AuthenticationError::ServiceAccount)?,
304 };
305
306 let matches_username = Filter::equals(&self.username_attribute, username);
310 let filter = match &self.extra_filter {
311 Some(extra) => Filter::and([extra.clone(), matches_username]),
312 None => matches_username,
313 };
314
315 let request = SearchRequest::new(&self.base_dn, filter)
316 .scope(self.scope)
317 .size_limit(2)
321 .attributes(self.attributes.clone());
322
323 let outcome =
324 connection.search(&request).await.map_err(AuthenticationError::Unreachable)?;
325 let _ = connection.unbind().await;
326
327 if !outcome.result.is_success() && outcome.result.code != ResultCode::SizeLimitExceeded {
331 return Err(AuthenticationError::Directory(outcome.result));
332 }
333
334 match outcome.entries.len() {
335 0 => Err(AuthenticationError::NoSuchUser),
336 1 => Ok(outcome.entries.into_iter().next().expect("length checked")),
337 matched => Err(AuthenticationError::Ambiguous { matched }),
341 }
342 }
343
344 pub async fn search(&self, filter: Filter, attributes: &[&str]) -> Result<Vec<SearchEntry>> {
349 let mut connection = LdapConnection::connect(&self.config).await?;
350
351 match &self.service {
352 ServiceIdentity::Anonymous => {
353 connection.bind_anonymous().await?.into_result("anonymous bind")?
354 }
355 ServiceIdentity::Simple { dn, password } => {
356 connection.bind(dn, password).await?.into_result("service account bind")?
357 }
358 };
359
360 let request =
361 SearchRequest::new(&self.base_dn, filter).scope(self.scope).attributes(attributes.iter().copied());
362 let outcome = connection.search(&request).await?;
363 let _ = connection.unbind().await;
364
365 outcome.result.into_result("search")?;
366 Ok(outcome.entries)
367 }
368
369 async fn connect(&self) -> std::result::Result<LdapConnection, AuthenticationError> {
370 LdapConnection::connect(&self.config).await.map_err(AuthenticationError::Unreachable)
371 }
372}
373
374fn classify(result: LdapResult) -> std::result::Result<(), AuthenticationError> {
376 if result.is_success() {
377 return Ok(());
378 }
379
380 match result.code {
381 ResultCode::InvalidCredentials => match AccountProblem::from_diagnostic(&result.diagnostic)
382 {
383 Some(AccountProblem::NoSuchAccount) => Err(AuthenticationError::NoSuchUser),
387 Some(AccountProblem::WrongPassword) | None => {
388 Err(AuthenticationError::InvalidPassword)
389 }
390 Some(reason) => {
391 Err(AuthenticationError::AccountUnusable { reason, diagnostic: result.diagnostic })
392 }
393 },
394 ResultCode::UnwillingToPerform | ResultCode::InappropriateAuthentication => {
397 Err(AuthenticationError::AccountUnusable {
398 reason: AccountProblem::from_diagnostic(&result.diagnostic)
399 .unwrap_or(AccountProblem::Unspecified),
400 diagnostic: result.diagnostic,
401 })
402 }
403 ResultCode::NoSuchObject => Err(AuthenticationError::NoSuchUser),
405 _ => Err(AuthenticationError::Directory(result)),
406 }
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum AccountProblem {
412 NoSuchAccount,
415 WrongPassword,
417 TimeRestricted,
419 WorkstationRestricted,
421 PasswordExpired,
423 Disabled,
425 Expired,
427 MustChangePassword,
429 Locked,
431 Unspecified,
433}
434
435impl AccountProblem {
436 pub fn from_diagnostic(diagnostic: &str) -> Option<AccountProblem> {
448 let after = diagnostic.split("data ").nth(1)?;
449 let code: String =
450 after.chars().take_while(|character| character.is_ascii_hexdigit()).collect();
451
452 match code.to_ascii_lowercase().as_str() {
453 "525" => Some(AccountProblem::NoSuchAccount),
454 "52e" => Some(AccountProblem::WrongPassword),
455 "530" => Some(AccountProblem::TimeRestricted),
456 "531" => Some(AccountProblem::WorkstationRestricted),
457 "532" => Some(AccountProblem::PasswordExpired),
458 "533" => Some(AccountProblem::Disabled),
459 "701" => Some(AccountProblem::Expired),
460 "773" => Some(AccountProblem::MustChangePassword),
461 "775" => Some(AccountProblem::Locked),
462 _ => None,
463 }
464 }
465
466 pub fn as_str(self) -> &'static str {
467 match self {
468 AccountProblem::NoSuchAccount => "the account does not exist",
469 AccountProblem::WrongPassword => "the password is wrong",
470 AccountProblem::TimeRestricted => "the account may not log in at this time of day",
471 AccountProblem::WorkstationRestricted => {
472 "the account may not log in from this workstation"
473 }
474 AccountProblem::PasswordExpired => "the password has expired",
475 AccountProblem::Disabled => "the account is disabled",
476 AccountProblem::Expired => "the account has expired",
477 AccountProblem::MustChangePassword => "the password must be changed before logging in",
478 AccountProblem::Locked => "the account is locked out",
479 AccountProblem::Unspecified => "the directory refused without saying why",
480 }
481 }
482}
483
484impl std::fmt::Display for AccountProblem {
485 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486 f.write_str(self.as_str())
487 }
488}
489
490#[derive(Debug)]
496pub enum AuthenticationError {
497 EmptyPassword,
503 EmptyUsername,
505 NoSuchUser,
507 InvalidPassword,
509 Ambiguous { matched: usize },
512 AccountUnusable { reason: AccountProblem, diagnostic: String },
514 Unreachable(Error),
516 ServiceAccount(Error),
519 Directory(LdapResult),
521}
522
523impl AuthenticationError {
524 pub fn is_credential_failure(&self) -> bool {
534 matches!(
535 self,
536 AuthenticationError::EmptyPassword
537 | AuthenticationError::EmptyUsername
538 | AuthenticationError::NoSuchUser
539 | AuthenticationError::InvalidPassword
540 )
541 }
542
543 pub fn is_directory_failure(&self) -> bool {
545 matches!(
546 self,
547 AuthenticationError::Unreachable(_)
548 | AuthenticationError::ServiceAccount(_)
549 | AuthenticationError::Directory(_)
550 | AuthenticationError::Ambiguous { .. }
551 )
552 }
553}
554
555impl std::fmt::Display for AuthenticationError {
556 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
557 match self {
558 AuthenticationError::EmptyPassword => f.write_str(
559 "the password was empty; LDAP would have treated that as an anonymous bind and \
560 answered `success`, so it was refused here instead",
561 ),
562 AuthenticationError::EmptyUsername => f.write_str("the username was empty"),
563 AuthenticationError::NoSuchUser => {
564 f.write_str("the directory has no entry with that username")
565 }
566 AuthenticationError::InvalidPassword => {
567 f.write_str("the directory rejected that password")
568 }
569 AuthenticationError::Ambiguous { matched } => write!(
570 f,
571 "{matched} entries share that username, so there is no one account to log in as"
572 ),
573 AuthenticationError::AccountUnusable { reason, diagnostic } => {
574 write!(f, "the directory would not let that account log in: {reason}")?;
575 if !diagnostic.is_empty() {
576 write!(f, " ({diagnostic})")?;
577 }
578 Ok(())
579 }
580 AuthenticationError::Unreachable(error) => {
581 write!(f, "the directory could not be reached: {error}")
582 }
583 AuthenticationError::ServiceAccount(error) => {
584 write!(f, "this application could not bind as its own service account: {error}")
585 }
586 AuthenticationError::Directory(result) => {
587 write!(f, "the directory refused the bind: {}", result.code)?;
588 if !result.diagnostic.is_empty() {
589 write!(f, " — {}", result.diagnostic)?;
590 }
591 Ok(())
592 }
593 }
594 }
595}
596
597impl std::error::Error for AuthenticationError {
598 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
599 match self {
600 AuthenticationError::Unreachable(error)
601 | AuthenticationError::ServiceAccount(error) => Some(error),
602 _ => None,
603 }
604 }
605}
606
607impl From<AuthenticationError> for Error {
608 fn from(error: AuthenticationError) -> Error {
609 Error::msg(error.to_string())
610 }
611}
612
613#[derive(Debug, Clone)]
615pub struct User {
616 pub dn: String,
619 pub attributes: Vec<Attribute>,
620}
621
622impl User {
623 pub fn dn(&self) -> &str {
624 &self.dn
625 }
626
627 pub fn value(&self, name: &str) -> Option<&str> {
630 self.attributes
631 .iter()
632 .find(|attribute| attribute.name.eq_ignore_ascii_case(name))
633 .and_then(Attribute::first)
634 }
635
636 pub fn values(&self, name: &str) -> Vec<&str> {
638 self.attributes
639 .iter()
640 .find(|attribute| attribute.name.eq_ignore_ascii_case(name))
641 .map(|attribute| {
642 attribute
643 .values
644 .iter()
645 .filter_map(|value| std::str::from_utf8(value).ok())
646 .collect()
647 })
648 .unwrap_or_default()
649 }
650}
651
652impl From<SearchEntry> for User {
653 fn from(entry: SearchEntry) -> User {
654 User { dn: entry.dn, attributes: entry.attributes }
655 }
656}
657
658impl Authenticatable for User {
664 fn auth_identifier(&self) -> String {
665 self.dn.clone()
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[tokio::test]
674 async fn an_empty_password_is_refused_before_it_reaches_the_wire() {
675 let directory = Directory::new("ldaps://127.0.0.1:1")
687 .unwrap()
688 .base_dn("dc=example,dc=test")
689 .service_account("cn=svc,dc=example,dc=test", "secret")
690 .connect_timeout(Duration::from_millis(200));
691
692 let error = directory.authenticate("alice", "").await.unwrap_err();
693
694 assert!(
695 matches!(error, AuthenticationError::EmptyPassword),
696 "an empty password must be its own answer, not a connection failure: {error}"
697 );
698 assert!(error.is_credential_failure());
699 assert!(error.to_string().contains("anonymous"), "and say why: {error}");
700
701 let error = directory.authenticate("alice", "hunter2").await.unwrap_err();
705 assert!(
706 matches!(error, AuthenticationError::Unreachable(_)),
707 "expected to reach the network, got {error}"
708 );
709
710 let error = directory.authenticate("alice", " ").await.unwrap_err();
712 assert!(matches!(error, AuthenticationError::Unreachable(_)), "got {error}");
713 }
714
715 #[tokio::test]
716 async fn an_empty_username_is_refused_too() {
717 let directory = Directory::new("ldaps://127.0.0.1:1")
718 .unwrap()
719 .connect_timeout(Duration::from_millis(200));
720
721 assert!(matches!(
722 directory.authenticate("", "hunter2").await.unwrap_err(),
723 AuthenticationError::EmptyUsername
724 ));
725 assert!(matches!(
728 directory.authenticate("alice\0", "hunter2").await.unwrap_err(),
729 AuthenticationError::EmptyUsername
730 ));
731 }
732
733 #[test]
734 fn a_username_becomes_a_value_and_never_a_filter() {
735 let filter = Filter::and([
739 Filter::equals("objectClass", "inetOrgPerson"),
740 Filter::equals("uid", "*)(uid=admin"),
741 ]);
742
743 let mut encoder = crate::ber::Encoder::new();
744 filter.encode(&mut encoder);
745 let bytes = encoder.into_bytes();
746
747 assert_eq!(bytes[0], 0xa0);
750 assert_eq!(bytes.iter().filter(|&&byte| byte == 0xa3).count(), 2);
751 assert!(
752 bytes.windows(12).any(|window| window == b"*)(uid=admin"),
753 "the value is carried verbatim, as data"
754 );
755
756 assert_eq!(
759 filter.to_string(),
760 "(&(objectClass=inetOrgPerson)(uid=\\2a\\29\\28uid=admin))"
761 );
762 }
763
764 #[test]
765 fn active_directory_s_sub_codes_are_read_out_of_the_diagnostic() {
766 let disabled = "80090308: LdapErr: DSID-0C0903A9, comment: AcceptSecurityContext error, \
768 data 533, v2580";
769 assert_eq!(AccountProblem::from_diagnostic(disabled), Some(AccountProblem::Disabled));
770
771 for (code, expected) in [
772 ("525", AccountProblem::NoSuchAccount),
773 ("52e", AccountProblem::WrongPassword),
774 ("530", AccountProblem::TimeRestricted),
775 ("531", AccountProblem::WorkstationRestricted),
776 ("532", AccountProblem::PasswordExpired),
777 ("533", AccountProblem::Disabled),
778 ("701", AccountProblem::Expired),
779 ("773", AccountProblem::MustChangePassword),
780 ("775", AccountProblem::Locked),
781 ] {
782 let diagnostic = format!("80090308: LdapErr: ..., data {code}, v2580");
783 assert_eq!(AccountProblem::from_diagnostic(&diagnostic), Some(expected), "{code}");
784 let upper = format!("80090308: LdapErr: ..., data {}, v2580", code.to_uppercase());
786 assert_eq!(AccountProblem::from_diagnostic(&upper), Some(expected), "{code}");
787 }
788
789 assert_eq!(AccountProblem::from_diagnostic(""), None);
791 assert_eq!(AccountProblem::from_diagnostic("Invalid credentials"), None);
792 assert_eq!(AccountProblem::from_diagnostic("..., data 999, ..."), None);
793 }
794
795 #[test]
796 fn a_disabled_account_is_not_reported_as_a_wrong_password() {
797 let wrong = LdapResult {
800 code: ResultCode::InvalidCredentials,
801 matched_dn: String::new(),
802 diagnostic: "80090308: LdapErr: ..., data 52e, v2580".into(),
803 referrals: Vec::new(),
804 };
805 assert!(matches!(classify(wrong).unwrap_err(), AuthenticationError::InvalidPassword));
806
807 let disabled = LdapResult {
808 code: ResultCode::InvalidCredentials,
809 matched_dn: String::new(),
810 diagnostic: "80090308: LdapErr: ..., data 533, v2580".into(),
811 referrals: Vec::new(),
812 };
813 let error = classify(disabled).unwrap_err();
814 assert!(matches!(
815 error,
816 AuthenticationError::AccountUnusable { reason: AccountProblem::Disabled, .. }
817 ));
818 assert!(error.to_string().contains("disabled"), "got {error}");
819
820 assert!(!error.is_credential_failure());
823
824 let plain = LdapResult {
826 code: ResultCode::InvalidCredentials,
827 matched_dn: String::new(),
828 diagnostic: String::new(),
829 referrals: Vec::new(),
830 };
831 assert!(matches!(classify(plain).unwrap_err(), AuthenticationError::InvalidPassword));
832
833 assert!(
835 classify(LdapResult {
836 code: ResultCode::Success,
837 matched_dn: String::new(),
838 diagnostic: String::new(),
839 referrals: Vec::new(),
840 })
841 .is_ok()
842 );
843 }
844
845 #[test]
846 fn credential_failures_are_grouped_so_a_form_can_say_one_thing() {
847 for error in [
848 AuthenticationError::EmptyPassword,
849 AuthenticationError::EmptyUsername,
850 AuthenticationError::NoSuchUser,
851 AuthenticationError::InvalidPassword,
852 ] {
853 assert!(error.is_credential_failure(), "{error}");
854 assert!(!error.is_directory_failure(), "{error}");
855 }
856
857 for error in [
858 AuthenticationError::Unreachable(Error::msg("connection refused")),
859 AuthenticationError::ServiceAccount(Error::msg("bad password")),
860 AuthenticationError::Ambiguous { matched: 2 },
861 ] {
862 assert!(!error.is_credential_failure(), "{error}");
863 assert!(error.is_directory_failure(), "{error}");
864 }
865 }
866
867 #[test]
868 fn a_user_is_keyed_on_its_dn_and_reads_attributes_case_insensitively() {
869 let user = User::from(SearchEntry {
870 dn: "uid=alice,ou=people,dc=example,dc=test".into(),
871 attributes: vec![
872 Attribute { name: "cn".into(), values: vec![b"Alice Liddell".to_vec()] },
873 Attribute {
874 name: "memberOf".into(),
875 values: vec![b"cn=staff".to_vec(), b"cn=admins".to_vec()],
876 },
877 ],
878 });
879
880 assert_eq!(user.auth_identifier(), "uid=alice,ou=people,dc=example,dc=test");
881 assert_eq!(user.value("CN"), Some("Alice Liddell"));
882 assert_eq!(user.values("memberof"), vec!["cn=staff", "cn=admins"]);
883 assert_eq!(user.value("mail"), None);
884 assert!(user.values("mail").is_empty());
885 }
886
887 #[test]
888 fn a_dn_template_escapes_the_username_into_the_dn() {
889 let template = "uid={username},ou=people,dc=example,dc=test";
892 let hostile = crate::protocol::escape_dn_value("alice,ou=admins");
893
894 assert_eq!(
895 template.replace("{username}", &hostile),
896 "uid=alice\\,ou\\=admins,ou=people,dc=example,dc=test",
897 "the comma stays inside the first RDN instead of starting a new one"
898 );
899 }
900}