feat: Implement LDAP bind/whoami

This commit is contained in:
selfhoster selfhoster 2026-09-01 21:18:19 +02:00
commit b4330b37fa
19 changed files with 664 additions and 37 deletions

View file

@ -26,11 +26,13 @@ impl<D: DatabaseInterface> Database<D> {
/// TODO: we may want to make sure the method runs in constant time
/// to avoid leaking information about existing users...
/// or maybe we do not care.
async fn check_password(&self, user: &UserRef, password: &str) -> bool {
pub async fn check_password(&self, user: &UserRef, password: &str) -> bool {
let Some(user) = self.get_user(user).await else {
tracing::debug!("check_password: User not found {user}");
return false;
};
tracing::debug!("Comparing {} and {}", user.password, password);
user.password == password
}
}

View file

@ -1,4 +1,4 @@
use crate::db::error::*;
use crate::db::error::UserAlreadyExists;
use crate::db::{Database, User, UserRef};
impl<D: DatabaseInterface> DatabaseInterface for Database<D> {

View file

@ -1,6 +1,6 @@
use std::future::{Future, ready};
use crate::db::error::*;
use crate::db::error::UserAlreadyExists;
use crate::db::{Database, DatabaseInterface, Group, User, UserRef};
#[derive(Clone, Debug, Default)]

35
src/ldap/client_state.rs Normal file
View file

@ -0,0 +1,35 @@
use crate::ldap::Dn;
/// Whether a client is successfully logged in (bound).
///
/// As it is, this is currently only used for the whoami operation.
#[derive(Clone, Debug)]
pub enum LdapClientState {
Unbound,
Bound(Dn),
}
impl LdapClientState {
pub fn new() -> Self {
Self::Unbound
}
pub fn unbind(&mut self) {
*self = Self::Unbound;
}
pub fn bind(&mut self, dn: Dn) {
*self = Self::Bound(dn);
}
pub fn bound_dn(&self) -> Option<&Dn> {
match self {
Self::Unbound => None,
Self::Bound(dn) => Some(dn),
}
}
pub fn bound_dn_string(&self) -> String {
self.bound_dn().map_or(String::new(), Dn::to_dn_string)
}
}

222
src/ldap/dn.rs Normal file
View file

@ -0,0 +1,222 @@
use dn_escape::dn_escape;
use ldap3_proto::LdapResultCode;
use crate::db::UserRef;
use crate::ldap::LdapReturnError;
/// A simple multi-value Map powered by a vector, to respect
/// order of first appearance of keys.
#[derive(Clone, Debug)]
pub struct VecMap {
inner: Vec<(String, Vec<String>)>,
}
impl VecMap {
pub fn new() -> Self {
Self { inner: vec![] }
}
pub fn get(&self, key: &str) -> Option<&Vec<String>> {
for (prev_key, prev_values) in &self.inner {
if key == prev_key {
return Some(prev_values);
}
}
None
}
pub fn insert_or_append(&mut self, key: &str, value: &str) {
for (prev_key, prev_values) in &mut self.inner {
if key == prev_key {
prev_values.push(value.to_string());
return;
}
}
self.inner.push((key.to_string(), vec![value.to_string()]));
}
pub fn insert(&mut self, key: &str, values: Vec<String>, overwrite: bool) {
for (prev_key, prev_values) in &mut self.inner {
if key == prev_key {
if overwrite {
*prev_values = values;
}
return;
}
}
self.inner.push((key.to_string(), values));
}
}
#[derive(Clone, Debug)]
pub struct InvalidDnError(pub String);
impl LdapReturnError for InvalidDnError {
fn code(&self) -> LdapResultCode {
LdapResultCode::InvalidDNSyntax
}
fn message(&self) -> String {
format!("Invalid dn: {}", self.0)
}
}
#[derive(Clone, Debug)]
pub struct NotUserDnError(pub String);
impl LdapReturnError for NotUserDnError {
fn code(&self) -> LdapResultCode {
LdapResultCode::InvalidDNSyntax
}
fn message(&self) -> String {
format!("Not a user dn containing uid/dc: {}", self.0)
}
}
/// A Dn is a key-value mapping which can contain the same key several times.
///
/// This implementation is not fully RFC compliant and will only parse simple DNs for
/// basic attribute manipulation.
///
/// Keys are lowercased, but scrambled keys in a broken order will be reordered. For example,
/// `dc=example,ou=people,dc=com` will become `dc=example,dc=com,ou=people`.
#[derive(Clone, Debug)]
pub struct Dn {
pub keys: VecMap,
}
impl Dn {
/// Parse a DN string. Here parsing escaped characters is not critical, if a
/// client sends us funny characters, the domain name simply won't match
/// and their request won't go anywhere.
///
/// However, if we find a really funny request such as `dc=foo=bar`, then
/// we return an error to the client.
pub fn from_dn_str(input: &str) -> Result<Self, InvalidDnError> {
let mut keys = VecMap::new();
if input.is_empty() {
return Ok(Self { keys });
}
for query in input.split(',') {
let mut query_parts = query.split('=');
// Here we have key=val pairs
if query_parts.clone().count() != 2 {
// Bad request
return Err(InvalidDnError(input.to_string()));
}
let key = query_parts.next().unwrap();
let val = query_parts.next().unwrap();
keys.insert_or_append(key, val);
}
Ok(Self { keys })
}
pub fn to_dn_string(&self) -> String {
let mut s = String::new();
let mut first = true;
for (key, values) in &self.keys.inner {
for value in values {
if first {
first = false;
} else {
s.push(',');
}
s.push_str(key);
s.push('=');
s.push_str(value);
}
}
s
}
/// Gets the hostname defined in the `dc` fields of the DN.
///
/// For example, `dc=example,dc=com` becomes `Some(example.com)`.
///
/// The returned domain is not normalized and may require casing treatment
/// to compare meaningfully.
pub fn get_hostname(&self) -> Option<String> {
let domain_components = self.keys.get("dc")?;
// We don't populate the dc key if there was no value at all, so
// we have at least one component.
let mut domain_components = domain_components.iter();
let mut s = String::from(domain_components.next().unwrap());
for domain_component in domain_components {
s.push('.');
s.push_str(domain_component);
}
Some(s)
}
/// Overrides the DN hostname (`dc` fields) with the provided host.
///
/// When no `dc` fields are present, they are only added when `force` is true.
pub fn set_hostname(&mut self, host: &str, force: bool) {
let domain_components: Vec<String> =
host.split('.').map(|x| dn_escape(x).to_string()).collect();
self.keys.insert("dc", domain_components, force);
}
pub fn to_user_ref(&self) -> Result<UserRef, NotUserDnError> {
let Some(username_vals) = self.keys.get("uid") else {
return Err(NotUserDnError(self.to_dn_string()));
};
if username_vals.len() != 1 {
return Err(NotUserDnError(self.to_dn_string()));
}
let username = username_vals[0].clone();
let Some(domain_vals) = self.keys.get("dc") else {
return Err(NotUserDnError(self.to_dn_string()));
};
let domain = domain_vals.join(".");
Ok(UserRef { username, domain })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_dn_str() {
let s = "cn=admin,ou=people,dc=example,dc=com";
let mut dn = Dn::from_dn_str(s).unwrap();
assert_eq!(&dn.to_dn_string(), s);
assert_eq!(dn.get_hostname().as_deref(), Some("example.com"));
dn.set_hostname("a.localhost", false);
assert_eq!(dn.get_hostname().as_deref(), Some("a.localhost"));
assert_eq!(&dn.to_dn_string(), "cn=admin,ou=people,dc=a,dc=localhost");
}
#[test]
fn test_empty_dn_str() {
let s = "";
let mut dn = Dn::from_dn_str(s).unwrap();
assert_eq!(&dn.to_dn_string(), s);
assert!(dn.get_hostname().is_none());
dn.set_hostname("a.localhost", false);
assert_eq!(dn.get_hostname().as_deref(), None);
assert_eq!(&dn.to_dn_string(), s);
dn.set_hostname("a.localhost", true);
assert_eq!(dn.get_hostname().as_deref(), Some("a.localhost"));
assert_eq!(&dn.to_dn_string(), "dc=a,dc=localhost");
}
}

View file

@ -1,26 +1,35 @@
use ldap3_proto::LdapMsg;
use ldap3_proto::proto::LdapOp;
use crate::db::{Database, DatabaseInterface};
use crate::ldap::{LdapStream, LdapStreamError};
use crate::ldap::{LdapClientState, LdapStream, LdapStreamError, op_bind, op_ext};
#[tracing::instrument(name = "ldap", skip(s, db), fields(session = %s.session))]
pub async fn ldap_handler<D: DatabaseInterface>(mut s: LdapStream, db: Database<D>) {
#[tracing::instrument(name = "ldap", skip(stream, db), fields(session = %stream.session))]
pub async fn ldap_handler<D: DatabaseInterface>(mut stream: LdapStream, mut db: Database<D>) {
tracing::info! {
remote_addr = ?s.remote_addr,
remote_addr = ?stream.remote_addr,
"New client connection"
};
let mut state = LdapClientState::new();
loop {
match s.next().await {
Ok(msg) => {
if let Err(e) = ldap_handler_inner(msg).await {
match stream.next().await {
Ok(msg) => match ldap_handler_inner(&mut stream, msg, &mut state, &mut db).await {
Ok(should_keep_alive) => {
if !should_keep_alive {
tracing::debug!("Finished connection");
return;
}
}
Err(e) => {
tracing::debug!(
reason = ?e,
"Failed to respond"
);
return;
}
}
},
Err(e) => {
tracing::debug!(
reason = ?e,
@ -32,7 +41,53 @@ pub async fn ldap_handler<D: DatabaseInterface>(mut s: LdapStream, db: Database<
}
}
pub async fn ldap_handler_inner(msg: LdapMsg) -> Result<(), LdapStreamError> {
/// Return true to keep the connection going, false to close it.
#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream))]
pub async fn ldap_handler_inner<D: DatabaseInterface>(
stream: &mut LdapStream,
msg: LdapMsg,
client_state: &mut LdapClientState,
db: &mut Database<D>,
) -> Result<bool, LdapStreamError> {
tracing::debug!(msg = ?msg, "Received LDAP message");
Ok(())
match msg {
// Disconnect
LdapMsg {
msgid: _,
op: LdapOp::UnbindRequest,
ctrl: _,
} => {
client_state.unbind();
// TODO: keep the connection open?
Ok(true)
}
LdapMsg {
msgid,
op: LdapOp::ExtendedRequest(ler),
ctrl: _,
} => {
op_ext(stream, ler, msgid, client_state).await?;
Ok(true)
}
LdapMsg {
msgid,
op: LdapOp::BindRequest(lbr),
ctrl: _,
} => {
if let Some(bound_dn) = op_bind(stream, db, lbr, msgid).await? {
tracing::debug!("Successful bind");
client_state.bind(bound_dn);
Ok(true)
} else {
tracing::debug!("Unsuccessful bind");
// TODO: abort connection here?
Ok(false)
}
}
// Unsupported message
_ => {
tracing::warn!("Unsupported client message, closing connection");
Ok(false)
}
}
}

View file

@ -1,4 +1,15 @@
mod client_state;
pub use client_state::LdapClientState;
mod dn;
pub use dn::{Dn, InvalidDnError, NotUserDnError};
mod handler;
mod op;
pub use handler::ldap_handler;
pub use op::bind::op_bind;
pub use op::ext::op_ext;
mod return_error;
pub use return_error::LdapReturnError;
mod stream;
pub use stream::{LdapStream, LdapStreamError};
pub use stream::LdapStream;
mod stream_error;
pub use stream_error::LdapStreamError;

129
src/ldap/op/bind.rs Normal file
View file

@ -0,0 +1,129 @@
use ldap3_proto::proto::{LdapBindCred, LdapBindRequest, LdapBindResponse, LdapOp, LdapResult};
use ldap3_proto::{LdapMsg, LdapResultCode};
use crate::db::{Database, DatabaseInterface};
use crate::ldap::{
Dn, InvalidDnError, LdapReturnError, LdapStream, LdapStreamError, NotUserDnError,
};
pub enum BindError {
// TODO: DB Error
InvalidCredentials,
InvalidDn(InvalidDnError),
NotUserDn(NotUserDnError),
UnsupportedSASL,
}
impl BindError {
pub async fn error_message(
&self,
stream: &mut LdapStream,
msgid: i32,
) -> Result<(), LdapStreamError> {
let resp_msg = LdapMsg {
msgid,
op: LdapOp::BindResponse(LdapBindResponse {
res: LdapResult {
code: self.code(),
matcheddn: String::new(),
message: self.message(),
referral: vec![],
},
saslcreds: None,
}),
ctrl: vec![],
};
stream.send(resp_msg).await?;
Ok(())
}
}
impl From<InvalidDnError> for BindError {
fn from(e: InvalidDnError) -> Self {
Self::InvalidDn(e)
}
}
impl LdapReturnError for BindError {
fn code(&self) -> LdapResultCode {
match self {
Self::InvalidCredentials => LdapResultCode::InvalidCredentials,
Self::InvalidDn(e) => e.code(),
Self::NotUserDn(e) => e.code(),
Self::UnsupportedSASL => LdapResultCode::OperationsError,
}
}
fn message(&self) -> String {
match self {
Self::InvalidCredentials => "Wrong username or password".to_string(),
Self::InvalidDn(e) => e.message(),
Self::NotUserDn(e) => e.message(),
Self::UnsupportedSASL => "SASL login is not supported".to_string(),
}
}
}
pub async fn bind_success(stream: &mut LdapStream, msgid: i32) -> Result<(), LdapStreamError> {
let resp_msg = LdapMsg {
msgid,
op: LdapOp::BindResponse(LdapBindResponse {
res: LdapResult {
code: LdapResultCode::Success,
matcheddn: String::new(),
message: String::new(),
referral: vec![],
},
saslcreds: None,
}),
ctrl: vec![],
};
stream.send(resp_msg).await?;
Ok(())
}
/// Tries to bind the user.
///
/// On success, returns `Ok(Some(bound_dn))`. `Ok(None)` means credentials failed,
/// either because the account does not exist, or the password is wrong.
pub async fn op_bind<D: DatabaseInterface>(
stream: &mut LdapStream,
db: &Database<D>,
req: LdapBindRequest,
msgid: i32,
) -> Result<Option<Dn>, LdapStreamError> {
let dn = match Dn::from_dn_str(&req.dn) {
Ok(dn) => dn,
Err(e) => {
BindError::InvalidDn(e).error_message(stream, msgid).await?;
return Ok(None);
}
};
let LdapBindCred::Simple(password) = req.cred else {
BindError::UnsupportedSASL
.error_message(stream, msgid)
.await?;
return Ok(None);
};
let user_ref = match dn.to_user_ref() {
Ok(user_ref) => user_ref,
Err(e) => {
BindError::NotUserDn(e).error_message(stream, msgid).await?;
return Ok(None);
}
};
if db.check_password(&user_ref, &password).await {
bind_success(stream, msgid).await?;
Ok(Some(dn))
} else {
BindError::InvalidCredentials
.error_message(stream, msgid)
.await?;
Ok(None)
}
}

43
src/ldap/op/ext.rs Normal file
View file

@ -0,0 +1,43 @@
use ldap3_proto::proto::{LdapExtendedRequest, LdapExtendedResponse, LdapOp, LdapResult};
use ldap3_proto::{LdapMsg, LdapResultCode};
use crate::ldap::{LdapClientState, LdapStream, LdapStreamError};
pub async fn op_ext(
stream: &mut LdapStream,
ler: LdapExtendedRequest,
msgid: i32,
client_state: &LdapClientState,
) -> Result<(), LdapStreamError> {
let response = match ler.name.as_str() {
"1.3.6.1.4.1.4203.1.11.3" => LdapOp::ExtendedResponse(LdapExtendedResponse {
res: LdapResult {
code: LdapResultCode::Success,
matcheddn: String::new(),
message: String::new(),
referral: vec![],
},
name: None,
value: Some(Vec::from(client_state.bound_dn_string())),
}),
_ => LdapOp::ExtendedResponse(LdapExtendedResponse {
res: LdapResult {
code: LdapResultCode::OperationsError,
matcheddn: String::new(),
message: "Unsupported extended operation".to_string(),
referral: vec![],
},
name: None,
value: None,
}),
};
stream
.send(LdapMsg {
msgid,
op: response,
ctrl: vec![],
})
.await?;
Ok(())
}

2
src/ldap/op/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod bind;
pub mod ext;

6
src/ldap/return_error.rs Normal file
View file

@ -0,0 +1,6 @@
use ldap3_proto::LdapResultCode;
pub trait LdapReturnError {
fn code(&self) -> LdapResultCode;
fn message(&self) -> String;
}

View file

@ -1,32 +1,12 @@
use futures_util::StreamExt;
use futures_util::{SinkExt, StreamExt};
use ldap3_proto::{LdapCodec, LdapMsg};
use tokio::time::{Duration, timeout};
use tokio_util::codec::Framed;
use uuid::Uuid;
use std::fmt;
use crate::ldap::LdapStreamError;
use crate::stream::{AbstractSocketAddr, AbstractStreamKind};
#[derive(Debug)]
pub enum LdapStreamError {
ClientClosed,
Timeout(Duration),
LdapError(std::io::Error),
}
impl fmt::Display for LdapStreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClientClosed => write!(f, "Client closed the connection"),
Self::Timeout(d) => write!(f, "Client timeout after {}ms", d.as_millis()),
Self::LdapError(e) => write!(f, "Failed to parse LDAP message: {e:?}"),
}
}
}
impl std::error::Error for LdapStreamError {}
pub struct LdapStream {
pub remote_addr: AbstractSocketAddr,
pub inner: Framed<AbstractStreamKind, LdapCodec>,
@ -66,4 +46,12 @@ impl LdapStream {
// because of transient IO error, or any other error condition.
msg.map_err(LdapStreamError::LdapError)
}
pub async fn send(&mut self, msg: LdapMsg) -> Result<(), LdapStreamError> {
tracing::debug!(msg=?msg, "Sending to client");
self.inner
.send(msg)
.await
.map_err(LdapStreamError::ClientSend)
}
}

26
src/ldap/stream_error.rs Normal file
View file

@ -0,0 +1,26 @@
use std::fmt;
use std::time::Duration;
/// A non-normal condition to close the LDAP stream.
///
/// For an error result that does not abort the connection, see [`LdapReturnError`](crate::ldap::LdapReturnError).
#[derive(Debug)]
pub enum LdapStreamError {
ClientClosed,
Timeout(Duration),
LdapError(std::io::Error),
ClientSend(std::io::Error),
}
impl fmt::Display for LdapStreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClientClosed => write!(f, "Client closed the connection"),
Self::Timeout(d) => write!(f, "Client timeout after {}ms", d.as_millis()),
Self::LdapError(e) => write!(f, "Failed to parse LDAP message: {e:?}"),
Self::ClientSend(e) => write!(f, "Failed to send message to the client: {e:?}"),
}
}
}
impl std::error::Error for LdapStreamError {}