use crate::connection::{LdapConfig, LdapConnection};
use crate::protocol::{
Attribute, Filter, LdapResult, ResultCode, Scope, SearchEntry, SearchRequest,
};
use rustlavel_auth::Authenticatable;
use rustlavel_core::{Error, Result};
use std::time::Duration;
enum ServiceIdentity {
Anonymous,
Simple { dn: String, password: String },
}
pub struct Directory {
config: LdapConfig,
service: ServiceIdentity,
base_dn: String,
username_attribute: String,
scope: Scope,
extra_filter: Option<Filter>,
attributes: Vec<String>,
dn_template: Option<String>,
}
impl Directory {
pub fn new(url: &str) -> Result<Directory> {
Ok(Directory::from_config(LdapConfig::parse(url)?))
}
pub fn from_config(config: LdapConfig) -> Directory {
Directory {
config,
service: ServiceIdentity::Anonymous,
base_dn: String::new(),
username_attribute: "uid".to_string(),
scope: Scope::Subtree,
extra_filter: None,
attributes: Vec::new(),
dn_template: None,
}
}
pub fn service_account(
mut self,
dn: impl Into<String>,
password: impl Into<String>,
) -> Directory {
self.service = ServiceIdentity::Simple { dn: dn.into(), password: password.into() };
self
}
pub fn anonymous_search(mut self) -> Directory {
self.service = ServiceIdentity::Anonymous;
self
}
pub fn base_dn(mut self, dn: impl Into<String>) -> Directory {
self.base_dn = dn.into();
self
}
pub fn username_attribute(mut self, attribute: impl Into<String>) -> Directory {
self.username_attribute = attribute.into();
self
}
pub fn scope(mut self, scope: Scope) -> Directory {
self.scope = scope;
self
}
pub fn filter(mut self, filter: Filter) -> Directory {
self.extra_filter = Some(filter);
self
}
pub fn attributes<S: Into<String>>(
mut self,
attributes: impl IntoIterator<Item = S>,
) -> Directory {
self.attributes = attributes.into_iter().map(Into::into).collect();
self
}
pub fn user_dn_template(mut self, template: impl Into<String>) -> Directory {
self.dn_template = Some(template.into());
self
}
pub fn timeout(mut self, timeout: Duration) -> Directory {
self.config.timeout = timeout;
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> Directory {
self.config.connect_timeout = timeout;
self
}
pub fn allow_plaintext_password(mut self) -> Directory {
self.config.allow_plaintext_password = true;
self
}
pub fn dangerously_accept_any_certificate(mut self) -> Directory {
self.config.verify_certificate = false;
self
}
pub fn config(&self) -> &LdapConfig {
&self.config
}
pub async fn authenticate(
&self,
username: &str,
password: &str,
) -> std::result::Result<User, AuthenticationError> {
if password.is_empty() {
return Err(AuthenticationError::EmptyPassword);
}
if username.is_empty() {
return Err(AuthenticationError::EmptyUsername);
}
if username.contains('\0') {
return Err(AuthenticationError::EmptyUsername);
}
match &self.dn_template {
Some(template) => self.authenticate_by_template(template, username, password).await,
None => self.authenticate_by_search(username, password).await,
}
}
async fn authenticate_by_search(
&self,
username: &str,
password: &str,
) -> std::result::Result<User, AuthenticationError> {
let entry = self.find(username).await?;
let mut connection = self.connect().await?;
let result = connection
.bind(&entry.dn, password)
.await
.map_err(AuthenticationError::Unreachable)?;
let _ = connection.unbind().await;
classify(result)?;
Ok(User::from(entry))
}
async fn authenticate_by_template(
&self,
template: &str,
username: &str,
password: &str,
) -> std::result::Result<User, AuthenticationError> {
let dn = template.replace("{username}", &crate::protocol::escape_dn_value(username));
let mut connection = self.connect().await?;
let result =
connection.bind(&dn, password).await.map_err(AuthenticationError::Unreachable)?;
let _ = connection.unbind().await;
classify(result)?;
Ok(User { dn, attributes: Vec::new() })
}
pub async fn find(
&self,
username: &str,
) -> std::result::Result<SearchEntry, AuthenticationError> {
if username.is_empty() || username.contains('\0') {
return Err(AuthenticationError::EmptyUsername);
}
let mut connection = self.connect().await?;
match &self.service {
ServiceIdentity::Anonymous => connection
.bind_anonymous()
.await
.map_err(AuthenticationError::ServiceAccount)?
.into_result("anonymous bind")
.map_err(AuthenticationError::ServiceAccount)?,
ServiceIdentity::Simple { dn, password } => connection
.bind(dn, password)
.await
.map_err(AuthenticationError::ServiceAccount)?
.into_result("service account bind")
.map_err(AuthenticationError::ServiceAccount)?,
};
let matches_username = Filter::equals(&self.username_attribute, username);
let filter = match &self.extra_filter {
Some(extra) => Filter::and([extra.clone(), matches_username]),
None => matches_username,
};
let request = SearchRequest::new(&self.base_dn, filter)
.scope(self.scope)
.size_limit(2)
.attributes(self.attributes.clone());
let outcome =
connection.search(&request).await.map_err(AuthenticationError::Unreachable)?;
let _ = connection.unbind().await;
if !outcome.result.is_success() && outcome.result.code != ResultCode::SizeLimitExceeded {
return Err(AuthenticationError::Directory(outcome.result));
}
match outcome.entries.len() {
0 => Err(AuthenticationError::NoSuchUser),
1 => Ok(outcome.entries.into_iter().next().expect("length checked")),
matched => Err(AuthenticationError::Ambiguous { matched }),
}
}
pub async fn search(&self, filter: Filter, attributes: &[&str]) -> Result<Vec<SearchEntry>> {
let mut connection = LdapConnection::connect(&self.config).await?;
match &self.service {
ServiceIdentity::Anonymous => {
connection.bind_anonymous().await?.into_result("anonymous bind")?
}
ServiceIdentity::Simple { dn, password } => {
connection.bind(dn, password).await?.into_result("service account bind")?
}
};
let request =
SearchRequest::new(&self.base_dn, filter).scope(self.scope).attributes(attributes.iter().copied());
let outcome = connection.search(&request).await?;
let _ = connection.unbind().await;
outcome.result.into_result("search")?;
Ok(outcome.entries)
}
async fn connect(&self) -> std::result::Result<LdapConnection, AuthenticationError> {
LdapConnection::connect(&self.config).await.map_err(AuthenticationError::Unreachable)
}
}
fn classify(result: LdapResult) -> std::result::Result<(), AuthenticationError> {
if result.is_success() {
return Ok(());
}
match result.code {
ResultCode::InvalidCredentials => match AccountProblem::from_diagnostic(&result.diagnostic)
{
Some(AccountProblem::NoSuchAccount) => Err(AuthenticationError::NoSuchUser),
Some(AccountProblem::WrongPassword) | None => {
Err(AuthenticationError::InvalidPassword)
}
Some(reason) => {
Err(AuthenticationError::AccountUnusable { reason, diagnostic: result.diagnostic })
}
},
ResultCode::UnwillingToPerform | ResultCode::InappropriateAuthentication => {
Err(AuthenticationError::AccountUnusable {
reason: AccountProblem::from_diagnostic(&result.diagnostic)
.unwrap_or(AccountProblem::Unspecified),
diagnostic: result.diagnostic,
})
}
ResultCode::NoSuchObject => Err(AuthenticationError::NoSuchUser),
_ => Err(AuthenticationError::Directory(result)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountProblem {
NoSuchAccount,
WrongPassword,
TimeRestricted,
WorkstationRestricted,
PasswordExpired,
Disabled,
Expired,
MustChangePassword,
Locked,
Unspecified,
}
impl AccountProblem {
pub fn from_diagnostic(diagnostic: &str) -> Option<AccountProblem> {
let after = diagnostic.split("data ").nth(1)?;
let code: String =
after.chars().take_while(|character| character.is_ascii_hexdigit()).collect();
match code.to_ascii_lowercase().as_str() {
"525" => Some(AccountProblem::NoSuchAccount),
"52e" => Some(AccountProblem::WrongPassword),
"530" => Some(AccountProblem::TimeRestricted),
"531" => Some(AccountProblem::WorkstationRestricted),
"532" => Some(AccountProblem::PasswordExpired),
"533" => Some(AccountProblem::Disabled),
"701" => Some(AccountProblem::Expired),
"773" => Some(AccountProblem::MustChangePassword),
"775" => Some(AccountProblem::Locked),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
AccountProblem::NoSuchAccount => "the account does not exist",
AccountProblem::WrongPassword => "the password is wrong",
AccountProblem::TimeRestricted => "the account may not log in at this time of day",
AccountProblem::WorkstationRestricted => {
"the account may not log in from this workstation"
}
AccountProblem::PasswordExpired => "the password has expired",
AccountProblem::Disabled => "the account is disabled",
AccountProblem::Expired => "the account has expired",
AccountProblem::MustChangePassword => "the password must be changed before logging in",
AccountProblem::Locked => "the account is locked out",
AccountProblem::Unspecified => "the directory refused without saying why",
}
}
}
impl std::fmt::Display for AccountProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug)]
pub enum AuthenticationError {
EmptyPassword,
EmptyUsername,
NoSuchUser,
InvalidPassword,
Ambiguous { matched: usize },
AccountUnusable { reason: AccountProblem, diagnostic: String },
Unreachable(Error),
ServiceAccount(Error),
Directory(LdapResult),
}
impl AuthenticationError {
pub fn is_credential_failure(&self) -> bool {
matches!(
self,
AuthenticationError::EmptyPassword
| AuthenticationError::EmptyUsername
| AuthenticationError::NoSuchUser
| AuthenticationError::InvalidPassword
)
}
pub fn is_directory_failure(&self) -> bool {
matches!(
self,
AuthenticationError::Unreachable(_)
| AuthenticationError::ServiceAccount(_)
| AuthenticationError::Directory(_)
| AuthenticationError::Ambiguous { .. }
)
}
}
impl std::fmt::Display for AuthenticationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthenticationError::EmptyPassword => f.write_str(
"the password was empty; LDAP would have treated that as an anonymous bind and \
answered `success`, so it was refused here instead",
),
AuthenticationError::EmptyUsername => f.write_str("the username was empty"),
AuthenticationError::NoSuchUser => {
f.write_str("the directory has no entry with that username")
}
AuthenticationError::InvalidPassword => {
f.write_str("the directory rejected that password")
}
AuthenticationError::Ambiguous { matched } => write!(
f,
"{matched} entries share that username, so there is no one account to log in as"
),
AuthenticationError::AccountUnusable { reason, diagnostic } => {
write!(f, "the directory would not let that account log in: {reason}")?;
if !diagnostic.is_empty() {
write!(f, " ({diagnostic})")?;
}
Ok(())
}
AuthenticationError::Unreachable(error) => {
write!(f, "the directory could not be reached: {error}")
}
AuthenticationError::ServiceAccount(error) => {
write!(f, "this application could not bind as its own service account: {error}")
}
AuthenticationError::Directory(result) => {
write!(f, "the directory refused the bind: {}", result.code)?;
if !result.diagnostic.is_empty() {
write!(f, " — {}", result.diagnostic)?;
}
Ok(())
}
}
}
}
impl std::error::Error for AuthenticationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AuthenticationError::Unreachable(error)
| AuthenticationError::ServiceAccount(error) => Some(error),
_ => None,
}
}
}
impl From<AuthenticationError> for Error {
fn from(error: AuthenticationError) -> Error {
Error::msg(error.to_string())
}
}
#[derive(Debug, Clone)]
pub struct User {
pub dn: String,
pub attributes: Vec<Attribute>,
}
impl User {
pub fn dn(&self) -> &str {
&self.dn
}
pub fn value(&self, name: &str) -> Option<&str> {
self.attributes
.iter()
.find(|attribute| attribute.name.eq_ignore_ascii_case(name))
.and_then(Attribute::first)
}
pub fn values(&self, name: &str) -> Vec<&str> {
self.attributes
.iter()
.find(|attribute| attribute.name.eq_ignore_ascii_case(name))
.map(|attribute| {
attribute
.values
.iter()
.filter_map(|value| std::str::from_utf8(value).ok())
.collect()
})
.unwrap_or_default()
}
}
impl From<SearchEntry> for User {
fn from(entry: SearchEntry) -> User {
User { dn: entry.dn, attributes: entry.attributes }
}
}
impl Authenticatable for User {
fn auth_identifier(&self) -> String {
self.dn.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn an_empty_password_is_refused_before_it_reaches_the_wire() {
let directory = Directory::new("ldaps://127.0.0.1:1")
.unwrap()
.base_dn("dc=example,dc=test")
.service_account("cn=svc,dc=example,dc=test", "secret")
.connect_timeout(Duration::from_millis(200));
let error = directory.authenticate("alice", "").await.unwrap_err();
assert!(
matches!(error, AuthenticationError::EmptyPassword),
"an empty password must be its own answer, not a connection failure: {error}"
);
assert!(error.is_credential_failure());
assert!(error.to_string().contains("anonymous"), "and say why: {error}");
let error = directory.authenticate("alice", "hunter2").await.unwrap_err();
assert!(
matches!(error, AuthenticationError::Unreachable(_)),
"expected to reach the network, got {error}"
);
let error = directory.authenticate("alice", " ").await.unwrap_err();
assert!(matches!(error, AuthenticationError::Unreachable(_)), "got {error}");
}
#[tokio::test]
async fn an_empty_username_is_refused_too() {
let directory = Directory::new("ldaps://127.0.0.1:1")
.unwrap()
.connect_timeout(Duration::from_millis(200));
assert!(matches!(
directory.authenticate("", "hunter2").await.unwrap_err(),
AuthenticationError::EmptyUsername
));
assert!(matches!(
directory.authenticate("alice\0", "hunter2").await.unwrap_err(),
AuthenticationError::EmptyUsername
));
}
#[test]
fn a_username_becomes_a_value_and_never_a_filter() {
let filter = Filter::and([
Filter::equals("objectClass", "inetOrgPerson"),
Filter::equals("uid", "*)(uid=admin"),
]);
let mut encoder = crate::ber::Encoder::new();
filter.encode(&mut encoder);
let bytes = encoder.into_bytes();
assert_eq!(bytes[0], 0xa0);
assert_eq!(bytes.iter().filter(|&&byte| byte == 0xa3).count(), 2);
assert!(
bytes.windows(12).any(|window| window == b"*)(uid=admin"),
"the value is carried verbatim, as data"
);
assert_eq!(
filter.to_string(),
"(&(objectClass=inetOrgPerson)(uid=\\2a\\29\\28uid=admin))"
);
}
#[test]
fn active_directory_s_sub_codes_are_read_out_of_the_diagnostic() {
let disabled = "80090308: LdapErr: DSID-0C0903A9, comment: AcceptSecurityContext error, \
data 533, v2580";
assert_eq!(AccountProblem::from_diagnostic(disabled), Some(AccountProblem::Disabled));
for (code, expected) in [
("525", AccountProblem::NoSuchAccount),
("52e", AccountProblem::WrongPassword),
("530", AccountProblem::TimeRestricted),
("531", AccountProblem::WorkstationRestricted),
("532", AccountProblem::PasswordExpired),
("533", AccountProblem::Disabled),
("701", AccountProblem::Expired),
("773", AccountProblem::MustChangePassword),
("775", AccountProblem::Locked),
] {
let diagnostic = format!("80090308: LdapErr: ..., data {code}, v2580");
assert_eq!(AccountProblem::from_diagnostic(&diagnostic), Some(expected), "{code}");
let upper = format!("80090308: LdapErr: ..., data {}, v2580", code.to_uppercase());
assert_eq!(AccountProblem::from_diagnostic(&upper), Some(expected), "{code}");
}
assert_eq!(AccountProblem::from_diagnostic(""), None);
assert_eq!(AccountProblem::from_diagnostic("Invalid credentials"), None);
assert_eq!(AccountProblem::from_diagnostic("..., data 999, ..."), None);
}
#[test]
fn a_disabled_account_is_not_reported_as_a_wrong_password() {
let wrong = LdapResult {
code: ResultCode::InvalidCredentials,
matched_dn: String::new(),
diagnostic: "80090308: LdapErr: ..., data 52e, v2580".into(),
referrals: Vec::new(),
};
assert!(matches!(classify(wrong).unwrap_err(), AuthenticationError::InvalidPassword));
let disabled = LdapResult {
code: ResultCode::InvalidCredentials,
matched_dn: String::new(),
diagnostic: "80090308: LdapErr: ..., data 533, v2580".into(),
referrals: Vec::new(),
};
let error = classify(disabled).unwrap_err();
assert!(matches!(
error,
AuthenticationError::AccountUnusable { reason: AccountProblem::Disabled, .. }
));
assert!(error.to_string().contains("disabled"), "got {error}");
assert!(!error.is_credential_failure());
let plain = LdapResult {
code: ResultCode::InvalidCredentials,
matched_dn: String::new(),
diagnostic: String::new(),
referrals: Vec::new(),
};
assert!(matches!(classify(plain).unwrap_err(), AuthenticationError::InvalidPassword));
assert!(
classify(LdapResult {
code: ResultCode::Success,
matched_dn: String::new(),
diagnostic: String::new(),
referrals: Vec::new(),
})
.is_ok()
);
}
#[test]
fn credential_failures_are_grouped_so_a_form_can_say_one_thing() {
for error in [
AuthenticationError::EmptyPassword,
AuthenticationError::EmptyUsername,
AuthenticationError::NoSuchUser,
AuthenticationError::InvalidPassword,
] {
assert!(error.is_credential_failure(), "{error}");
assert!(!error.is_directory_failure(), "{error}");
}
for error in [
AuthenticationError::Unreachable(Error::msg("connection refused")),
AuthenticationError::ServiceAccount(Error::msg("bad password")),
AuthenticationError::Ambiguous { matched: 2 },
] {
assert!(!error.is_credential_failure(), "{error}");
assert!(error.is_directory_failure(), "{error}");
}
}
#[test]
fn a_user_is_keyed_on_its_dn_and_reads_attributes_case_insensitively() {
let user = User::from(SearchEntry {
dn: "uid=alice,ou=people,dc=example,dc=test".into(),
attributes: vec![
Attribute { name: "cn".into(), values: vec![b"Alice Liddell".to_vec()] },
Attribute {
name: "memberOf".into(),
values: vec![b"cn=staff".to_vec(), b"cn=admins".to_vec()],
},
],
});
assert_eq!(user.auth_identifier(), "uid=alice,ou=people,dc=example,dc=test");
assert_eq!(user.value("CN"), Some("Alice Liddell"));
assert_eq!(user.values("memberof"), vec!["cn=staff", "cn=admins"]);
assert_eq!(user.value("mail"), None);
assert!(user.values("mail").is_empty());
}
#[test]
fn a_dn_template_escapes_the_username_into_the_dn() {
let template = "uid={username},ou=people,dc=example,dc=test";
let hostile = crate::protocol::escape_dn_value("alice,ou=admins");
assert_eq!(
template.replace("{username}", &hostile),
"uid=alice\\,ou\\=admins,ou=people,dc=example,dc=test",
"the comma stays inside the first RDN instead of starting a new one"
);
}
}