Skip to main content

rustlavel_ldap/
directory.rs

1//! The part an application actually calls.
2//!
3//! Almost everything anyone wants from LDAP is one question: *is this the right
4//! password for this username?* Answering it correctly takes three steps that
5//! are easy to get subtly wrong, so they are one call here:
6//!
7//! 1. Bind as a service account, because an anonymous client usually cannot
8//!    read the attribute the username is stored in.
9//! 2. Search for the entry whose username attribute equals what was typed. The
10//!    username is a *value* in a typed [`Filter`], never text pasted into one.
11//! 3. Bind as that entry's DN with the password that was typed — on a fresh
12//!    connection, because a bind changes who a connection is, and a failed one
13//!    leaves it anonymous rather than closed.
14//!
15//! ```ignore
16//! use rustlavel_ldap::prelude::*;
17//!
18//! let directory = Directory::new("ldaps://dc.example.test")?
19//!     .service_account("cn=svc,dc=example,dc=test", &config.get("LDAP_PASSWORD")?)
20//!     .base_dn("ou=people,dc=example,dc=test")
21//!     .username_attribute("uid")
22//!     .attributes(["cn", "mail"]);
23//!
24//! match directory.authenticate(&form.username, &form.password).await {
25//!     Ok(user) => log_in(user.dn()),
26//!     Err(error) if error.is_credential_failure() => render_the_same_message_for_all_of_these(),
27//!     Err(error) => return Err(error.into()),
28//! }
29//! ```
30//!
31//! # Distinguishable failures, indistinguishable messages
32//!
33//! [`AuthenticationError`] separates "no such user" from "wrong password" from
34//! "the directory is down", because a log that cannot tell them apart is a log
35//! that cannot tell you your directory has been unreachable for an hour.
36//!
37//! The person at the login form must not be told which one it was. A form that
38//! says "no such user" for one name and "wrong password" for another is an
39//! account enumeration oracle. [`AuthenticationError::is_credential_failure`]
40//! exists to make the right thing easy: log the variant, show one message for
41//! everything it returns true for.
42
43use 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
51/// How the client authenticates itself before searching.
52///
53/// **No `Debug`.** It holds the service account's password.
54enum ServiceIdentity {
55    /// Search without binding at all. Legitimate — plenty of directories allow
56    /// an anonymous read of `uid` — and asked for by name so it cannot be
57    /// reached by leaving a password out of a configuration file.
58    Anonymous,
59    Simple { dn: String, password: String },
60}
61
62/// A directory, configured, ready to be asked about a user.
63///
64/// **No `Debug`.** It holds the service account's password.
65pub 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    /// A directory at an `ldap://` or `ldaps://` URL.
78    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            // The overwhelmingly common two: `uid` on anything derived from
88            // OpenLDAP, `sAMAccountName` on Active Directory.
89            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    /// Bind as this account before searching.
98    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    /// Search without binding first.
108    pub fn anonymous_search(mut self) -> Directory {
109        self.service = ServiceIdentity::Anonymous;
110        self
111    }
112
113    /// Where in the tree to look for people.
114    pub fn base_dn(mut self, dn: impl Into<String>) -> Directory {
115        self.base_dn = dn.into();
116        self
117    }
118
119    /// The attribute a username is stored in — `uid`, `sAMAccountName`,
120    /// `userPrincipalName`, `mail`.
121    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    /// An extra condition every user must also satisfy.
132    ///
133    /// A [`Filter`] rather than a string, because a string would be a filter
134    /// template, and a filter template is the thing this design exists to avoid.
135    /// `Filter::equals("objectClass", "inetOrgPerson")` is the usual one.
136    pub fn filter(mut self, filter: Filter) -> Directory {
137        self.extra_filter = Some(filter);
138        self
139    }
140
141    /// Which attributes to fetch for an authenticated user. Empty means all of
142    /// them, which is rarely what you want out of a directory.
143    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    /// Skip the search and bind against a DN built from the username.
152    ///
153    /// `{username}` is replaced with the typed name, escaped per RFC 4514. This
154    /// is how a lot of Active Directory deployments are set up
155    /// (`{username}@example.test`), and it needs no service account at all.
156    ///
157    /// The trade is real and worth stating: without a search there is no
158    /// distinction between "no such user" and "wrong password" — the directory
159    /// answers both with `invalidCredentials` — and no attributes come back
160    /// unless [`Directory::authenticate`] is followed by a lookup. Prefer the
161    /// search when you can read the directory at all.
162    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    /// Send a simple bind even though the connection is not encrypted. See
178    /// [`LdapConfig::allow_plaintext_password`].
179    pub fn allow_plaintext_password(mut self) -> Directory {
180        self.config.allow_plaintext_password = true;
181        self
182    }
183
184    /// See [`LdapConfig::dangerously_accept_any_certificate`].
185    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    /// Is this the right password for this username?
195    ///
196    /// The empty-password and empty-username checks come first, before any I/O,
197    /// so a blank form field can never be confused with a directory problem —
198    /// and so the answer for a blank password is the same whether the directory
199    /// is up or down.
200    pub async fn authenticate(
201        &self,
202        username: &str,
203        password: &str,
204    ) -> std::result::Result<User, AuthenticationError> {
205        // A simple bind with a zero-length password is an *anonymous* bind, and
206        // a directory answers it with `success`. A login form that passed a
207        // blank password straight through would therefore report a successful
208        // login for any username that exists. This is a real CVE pattern, and
209        // the guard is here as well as in the encoder because here is where it
210        // can be answered without touching the network.
211        if password.is_empty() {
212            return Err(AuthenticationError::EmptyPassword);
213        }
214        if username.is_empty() {
215            return Err(AuthenticationError::EmptyUsername);
216        }
217        // A NUL cannot appear in a DN or an attribute value, and a username
218        // containing one is not a username anybody typed.
219        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        // A second connection, not the service one. A bind replaces the
237        // connection's authorization identity, so binding as the user here
238        // would silently demote the service connection — and a *failed* bind
239        // leaves a connection anonymous rather than closed, which is worse: the
240        // next search on it would run with no rights and look like an empty
241        // directory.
242        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        // Escaped as a DN value: a comma ends an RDN, so an unescaped one in a
260        // username would move the bind to an entry the template never named.
261        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    /// Find a user's entry without checking their password.
273    ///
274    /// The search half of [`Directory::authenticate`], separated because
275    /// "does this account exist" is a question worth asking on its own — during
276    /// provisioning, or when a session outlives the account behind it.
277    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        // Bind as the service account first: an anonymous client usually cannot
288        // read `uid` at all, and a directory that answers an anonymous search
289        // with an empty result is indistinguishable from one where the user
290        // does not exist.
291        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        // The username goes in as a *value*. A `*` here is one byte, 0x2a,
307        // compared literally — not a wildcard, because BER octet strings have
308        // no metacharacters. This is the whole reason `Filter` is a type.
309        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            // Two, not one: one entry is an answer, two is an ambiguity worth
318            // refusing, and asking for a third would be paying for a number
319            // nobody acts on.
320            .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        // sizeLimitExceeded is a success as far as this is concerned: it means
328        // the directory stopped early, which only happens when there was more
329        // than one match, which is the ambiguity below.
330        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            // Two accounts with the same username is a directory that has been
338            // misconfigured, and picking the first one means picking somebody
339            // arbitrary to log in as.
340            matched => Err(AuthenticationError::Ambiguous { matched }),
341        }
342    }
343
344    /// Run an arbitrary search as the service account.
345    ///
346    /// The escape hatch, for group membership and anything else this package
347    /// does not model. Still a typed [`Filter`], so it is still not a template.
348    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
374/// Turn a bind's result code into the most specific failure it supports.
375fn 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            // Active Directory answers everything with 49 and hides the real
384            // reason in the diagnostic. Reading it is the difference between
385            // "wrong password" and "this account was disabled in March".
386            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        // "I will not do that", which for a bind means a policy said no: a
395        // locked account, or a password that must be changed first.
396        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        // The entry the search just found is gone, or was never bindable.
404        ResultCode::NoSuchObject => Err(AuthenticationError::NoSuchUser),
405        _ => Err(AuthenticationError::Directory(result)),
406    }
407}
408
409/// Why a directory would not let an account in.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum AccountProblem {
412    /// Active Directory's `data 525`: the account does not exist. Surfaced as
413    /// [`AuthenticationError::NoSuchUser`] rather than kept here.
414    NoSuchAccount,
415    /// `data 52e`: the password is wrong, and only the password.
416    WrongPassword,
417    /// `data 530`: not permitted to log on at this time of day.
418    TimeRestricted,
419    /// `data 531`: not permitted to log on from this workstation.
420    WorkstationRestricted,
421    /// `data 532`: the password has expired.
422    PasswordExpired,
423    /// `data 533`: the account is disabled.
424    Disabled,
425    /// `data 701`: the account itself has expired.
426    Expired,
427    /// `data 773`: the user must change their password before logging in.
428    MustChangePassword,
429    /// `data 775`: the account is locked out.
430    Locked,
431    /// The directory refused without saying which of these it was.
432    Unspecified,
433}
434
435impl AccountProblem {
436    /// Read Active Directory's hexadecimal sub-code out of a diagnostic.
437    ///
438    /// AD answers every bind failure with `invalidCredentials` and puts the
439    /// real reason in a message shaped like
440    /// `80090308: LdapErr: DSID-0C0903A9, comment: AcceptSecurityContext error,
441    /// data 533, v2580`. It is undocumented in any RFC and completely stable in
442    /// practice, and without it a help desk cannot tell a forgotten password
443    /// from a disabled account.
444    ///
445    /// Anything that is not Active Directory returns `None` here, which is the
446    /// right answer: OpenLDAP means exactly what its result code says.
447    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/// Why an authentication attempt did not succeed.
491///
492/// Every variant is distinguishable so that a log can say what happened. Not
493/// every variant may be *shown* to the person logging in — see
494/// [`AuthenticationError::is_credential_failure`].
495#[derive(Debug)]
496pub enum AuthenticationError {
497    /// The password field was empty.
498    ///
499    /// Its own variant rather than an `InvalidPassword`, because this one never
500    /// touched the network: LDAP would have answered it with `success`, and
501    /// this is the client refusing to ask.
502    EmptyPassword,
503    /// The username field was empty, or held a NUL.
504    EmptyUsername,
505    /// The search found nobody.
506    NoSuchUser,
507    /// The entry exists and the password is wrong.
508    InvalidPassword,
509    /// More than one entry has that username. Refused rather than resolved:
510    /// picking one means picking somebody arbitrary to log in as.
511    Ambiguous { matched: usize },
512    /// The password may well be right, but a policy says no.
513    AccountUnusable { reason: AccountProblem, diagnostic: String },
514    /// The directory could not be reached, or the connection failed part way.
515    Unreachable(Error),
516    /// The client's own service account could not bind. Not the user's problem,
517    /// and worth paging somebody about.
518    ServiceAccount(Error),
519    /// The directory refused for a reason this package does not model.
520    Directory(LdapResult),
521}
522
523impl AuthenticationError {
524    /// Whether this is "the credentials were wrong" in any of its forms.
525    ///
526    /// Show one identical message for every case where this is true. A form
527    /// that distinguishes "no such user" from "wrong password" tells an
528    /// attacker which usernames are real, one guess at a time — and it does it
529    /// for free, at any rate they like.
530    ///
531    /// Everything this returns false for is an operational problem, and saying
532    /// "we could not reach the directory" for those is both honest and safe.
533    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    /// Whether the directory itself is at fault rather than the credentials.
544    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/// A user the directory recognised, with whatever attributes were asked for.
614#[derive(Debug, Clone)]
615pub struct User {
616    /// The entry's distinguished name — the only identifier a directory
617    /// guarantees is unique and stable enough to key a session on.
618    pub dn: String,
619    pub attributes: Vec<Attribute>,
620}
621
622impl User {
623    pub fn dn(&self) -> &str {
624        &self.dn
625    }
626
627    /// The first value of an attribute, as text. Case-insensitive, because
628    /// attribute descriptions are.
629    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    /// Every value of an attribute that is valid text.
637    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
658/// The DN is what a session remembers.
659///
660/// Not the username: a person can be renamed, and two directories can disagree
661/// about which attribute a username lives in. A DN is the directory's own
662/// answer to "which entry is this".
663impl 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        // The bug this prevents, stated plainly: RFC 4511 §4.2 says a simple
676        // bind with a zero-length password is an *anonymous* bind, and a
677        // directory answers it with `success`. A login form that passed a blank
678        // password field straight through would therefore report a successful
679        // login for any username that exists. It has been a real CVE in real
680        // products more than once.
681        //
682        // This directory points at a port with nothing behind it. If the check
683        // were anywhere but in front of the I/O, the error would be a
684        // connection failure — so the assertion is as much about *which* error
685        // comes back as about the fact that one does.
686        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        // For contrast: with a password, the same call gets as far as the
702        // socket and fails there. That is the proof the guard above is the
703        // thing that stopped it, and not the network.
704        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        // A password of a single space is a password.
711        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        // A NUL is not something a person types; it is something a person
726        // sends, usually to truncate a string somewhere downstream.
727        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        // The filter the search would build for a hostile username. It is one
736        // equality assertion with an eighteen-byte value, not two clauses — the
737        // parentheses never had any syntax to escape into.
738        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        // Two equality assertions inside one `and`, and the injected text sits
748        // whole inside an octet string of its own length.
749        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        // And the string form, which is what a template would have produced,
757        // escapes all three metacharacters.
758        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        // The real shape of an AD diagnostic, verbatim.
767        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            // AD is inconsistent about the case of the hex digit.
785            let upper = format!("80090308: LdapErr: ..., data {}, v2580", code.to_uppercase());
786            assert_eq!(AccountProblem::from_diagnostic(&upper), Some(expected), "{code}");
787        }
788
789        // OpenLDAP means what its result code says and has no sub-code.
790        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        // Both come back as result code 49. Telling them apart is the point:
798        // one is the user's mistake, the other is a help desk ticket.
799        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        // A disabled account is not a credential failure: showing "wrong
821        // password" for it sends the user round in circles.
822        assert!(!error.is_credential_failure());
823
824        // OpenLDAP's plain 49, with nothing to read: the password was wrong.
825        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        // And success is success.
834        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        // Not an authentication test — the substitution itself, because getting
890        // it wrong moves the bind to an entry the template never described.
891        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}