use rustlavel_ldap::ber::Encoder;
use rustlavel_ldap::prelude::*;
use rustlavel_ldap::protocol::{LdapMessage, ProtocolOp, tags};
macro_rules! directory {
() => {
match std::env::var("LDAP_TEST_URL") {
Ok(url) if !url.is_empty() => url,
_ => {
eprintln!("skipping: LDAP_TEST_URL is not set");
return;
}
}
};
}
const BASE: &str = "dc=example,dc=test";
const ADMIN: &str = "cn=admin,dc=example,dc=test";
const ADMIN_PASSWORD: &str = "secret";
const ALICE: &str = "cn=alice,ou=users,dc=example,dc=test";
const ALICE_PASSWORD: &str = "alicepass";
fn directory(url: &str) -> Directory {
Directory::from_config(
LdapConfig::parse(url).unwrap().plaintext().allow_plaintext_password(),
)
.service_account(ADMIN, ADMIN_PASSWORD)
.base_dn(BASE)
.username_attribute("uid")
.attributes(["cn", "uid", "sn"])
}
#[tokio::test]
async fn a_correct_password_authenticates() {
let url = directory!();
let user = directory(&url).authenticate("alice", ALICE_PASSWORD).await.unwrap();
assert_eq!(user.dn(), ALICE);
assert_eq!(user.value("uid"), Some("alice"));
assert!(user.values("cn").contains(&"alice"), "got {:?}", user.values("cn"));
assert_eq!(
rustlavel_auth::Authenticatable::auth_identifier(&user),
ALICE
);
}
#[tokio::test]
async fn a_wrong_password_does_not() {
let url = directory!();
let error = directory(&url).authenticate("alice", "not-alicepass").await.unwrap_err();
assert!(
matches!(error, AuthenticationError::InvalidPassword),
"expected a rejected password, got {error}"
);
assert!(error.is_credential_failure());
}
#[tokio::test]
async fn an_empty_password_does_not_authenticate() {
let url = directory!();
let error = directory(&url).authenticate("alice", "").await.unwrap_err();
assert!(
matches!(error, AuthenticationError::EmptyPassword),
"expected the empty-password refusal, got {error}"
);
let mut encoder = Encoder::new();
encoder.sequence(|message| {
message.integer(1);
message.constructed(tags::BIND_REQUEST, |bind| {
bind.integer(3);
bind.string(ALICE);
bind.tagged_string(0x80, "");
});
});
let response = send_one(&url, &encoder.into_bytes()).await;
let ProtocolOp::BindResponse { result, .. } = response.op else {
panic!("expected a bind response");
};
assert_eq!(
result.code,
ResultCode::UnwillingToPerform,
"this directory answered an empty-password bind with `{}`. If that is `success`, the \
client-side refusal above is the only thing between a blank password field and a \
successful login — which is the case this guard exists for.",
result.code
);
assert!(!result.code.is_success());
}
#[tokio::test]
async fn an_asterisk_in_the_username_does_not_match_everybody() {
let url = directory!();
let directory = directory(&url);
let everybody = directory.search(Filter::present("uid"), &["uid"]).await.unwrap();
assert!(
everybody.len() >= 2,
"the fixture should have at least two people in it, found {}",
everybody.len()
);
let error = directory.authenticate("*", ALICE_PASSWORD).await.unwrap_err();
assert!(
matches!(error, AuthenticationError::NoSuchUser),
"an asterisk must not match every account; got {error}"
);
let error = directory.authenticate("alice*", ALICE_PASSWORD).await.unwrap_err();
assert!(matches!(error, AuthenticationError::NoSuchUser), "got {error}");
let error = directory.authenticate("*)(uid=bob", ALICE_PASSWORD).await.unwrap_err();
assert!(matches!(error, AuthenticationError::NoSuchUser), "got {error}");
}
#[tokio::test]
async fn a_missing_user_is_told_apart_from_a_wrong_password() {
let url = directory!();
let directory = directory(&url);
let error = directory.authenticate("nobody-by-that-name", ALICE_PASSWORD).await.unwrap_err();
assert!(matches!(error, AuthenticationError::NoSuchUser), "got {error}");
assert!(error.is_credential_failure());
}
#[tokio::test]
async fn a_simple_bind_over_plain_tcp_is_refused_without_the_opt_in() {
let url = directory!();
let directory = Directory::from_config(LdapConfig::parse(&url).unwrap().plaintext())
.service_account(ADMIN, ADMIN_PASSWORD)
.base_dn(BASE)
.username_attribute("uid");
let error = directory.authenticate("alice", ALICE_PASSWORD).await.unwrap_err();
let text = error.to_string();
assert!(text.contains("unencrypted"), "got {text}");
assert!(text.contains("ldaps://"), "the error should name the fix: {text}");
assert!(!error.is_credential_failure(), "this is not the user's fault");
}
#[tokio::test]
async fn a_directory_without_tls_fails_start_tls_rather_than_downgrading() {
let url = directory!();
let config = LdapConfig::parse(&url).unwrap();
assert_eq!(config.encryption, Encryption::StartTls, "`ldap://` must not mean plain text");
let error = match LdapConnection::connect(&config).await {
Ok(connection) => panic!(
"StartTLS appeared to succeed against a container with no certificate (encrypted: {})",
connection.is_encrypted()
),
Err(error) => error.to_string(),
};
assert!(error.contains("StartTLS"), "got {error}");
}
#[tokio::test]
async fn a_bad_service_account_is_its_own_failure_and_not_the_user_s() {
let url = directory!();
let directory = Directory::from_config(
LdapConfig::parse(&url).unwrap().plaintext().allow_plaintext_password(),
)
.service_account(ADMIN, "not-the-admin-password")
.base_dn(BASE)
.username_attribute("uid");
let error = directory.authenticate("alice", ALICE_PASSWORD).await.unwrap_err();
assert!(
matches!(error, AuthenticationError::ServiceAccount(_)),
"a broken service account must not look like a wrong user password; got {error}"
);
assert!(!error.is_credential_failure());
assert!(error.is_directory_failure());
assert!(error.to_string().contains("service account"), "got {error}");
}
#[tokio::test]
async fn a_dn_template_binds_without_a_service_account() {
let url = directory!();
let directory = Directory::from_config(
LdapConfig::parse(&url).unwrap().plaintext().allow_plaintext_password(),
)
.user_dn_template("cn={username},ou=users,dc=example,dc=test");
let user = directory.authenticate("alice", ALICE_PASSWORD).await.unwrap();
assert_eq!(user.dn(), ALICE);
assert!(user.attributes.is_empty());
let error = directory.authenticate("alice", "wrong").await.unwrap_err();
assert!(matches!(error, AuthenticationError::InvalidPassword), "got {error}");
let error = directory.authenticate("nobody", "wrong").await.unwrap_err();
assert!(matches!(error, AuthenticationError::InvalidPassword), "got {error}");
}
#[tokio::test]
async fn a_search_reads_entries_attributes_and_the_result_together() {
let url = directory!();
let mut connection = LdapConnection::connect(
&LdapConfig::parse(&url).unwrap().plaintext().allow_plaintext_password(),
)
.await
.unwrap();
assert!(!connection.is_encrypted(), "the fixture container has no TLS");
let result = connection.bind(ADMIN, ADMIN_PASSWORD).await.unwrap();
assert!(result.is_success(), "{}", result.code);
assert_eq!(connection.bound_as(), Some(ADMIN));
let request = SearchRequest::new(BASE, Filter::equals("uid", "alice"))
.scope(Scope::Subtree)
.attributes(["cn", "uid", "objectClass"]);
let outcome = connection.search(&request).await.unwrap();
assert!(outcome.result.is_success(), "{}", outcome.result.code);
assert_eq!(outcome.entries.len(), 1);
assert_eq!(outcome.entries[0].dn, ALICE);
assert_eq!(outcome.entries[0].value("uid"), Some("alice"));
assert!(outcome.entries[0].values("objectClass").contains(&"inetOrgPerson"));
let request = SearchRequest::new(
BASE,
Filter::and([Filter::present("uid"), Filter::equals("objectClass", "inetOrgPerson")]),
);
let outcome = connection.search(&request).await.unwrap();
assert!(outcome.entries.len() >= 2, "found {}", outcome.entries.len());
let request = SearchRequest::new(BASE, Filter::equals("uid", "*"));
let outcome = connection.search(&request).await.unwrap();
assert!(outcome.result.is_success());
assert!(outcome.entries.is_empty(), "a literal asterisk matched {}", outcome.entries.len());
connection.unbind().await.unwrap();
}
#[tokio::test]
async fn an_anonymous_bind_is_available_when_it_is_asked_for_by_name() {
let url = directory!();
let mut connection =
LdapConnection::connect(&LdapConfig::parse(&url).unwrap().plaintext()).await.unwrap();
let result = connection.bind_anonymous().await.unwrap();
assert!(result.is_success(), "{}", result.code);
assert_eq!(connection.bound_as(), None);
connection.unbind().await.unwrap();
}
async fn send_one(url: &str, message: &[u8]) -> LdapMessage {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let config = LdapConfig::parse(url).unwrap();
let mut stream = tokio::net::TcpStream::connect(config.address()).await.unwrap();
stream.write_all(message).await.unwrap();
stream.flush().await.unwrap();
let mut buffer = Vec::new();
loop {
match rustlavel_ldap::ber::element_size(&buffer, 1 << 20).unwrap() {
Some(size) => return LdapMessage::parse(&buffer[..size]).unwrap(),
None => {
let mut chunk = [0u8; 1024];
let read = stream.read(&mut chunk).await.unwrap();
assert!(read > 0, "the directory closed without answering");
buffer.extend_from_slice(&chunk[..read]);
}
}
}
}