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

6
Cargo.lock generated
View file

@ -63,6 +63,10 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "dn_escape"
version = "0.1.0"
[[package]]
name = "futures-core"
version = "0.3.34"
@ -100,6 +104,7 @@ checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
@ -172,6 +177,7 @@ version = "0.1.0"
dependencies = [
"argh",
"camino",
"dn_escape",
"futures-util",
"ldap3_proto",
"serde",

View file

@ -6,7 +6,8 @@ edition = "2024"
[dependencies]
argh = "0.1.19"
camino = "1.2.5"
futures-util = "0.3.34"
dn_escape = { path = "vendor/dn_escape" }
futures-util = { version = "0.3.34", features = ["sink"] }
ldap3_proto = "0.8.1"
serde = { version = "1.0.229", features = ["derive"] }
tokio = { version = "1.53.1", features = ["macros", "net", "rt", "time", "sync"] }

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 {}

6
vendor/dn_escape/Cargo.toml vendored Normal file
View file

@ -0,0 +1,6 @@
[package]
name = "dn_escape"
version = "0.1.0"
edition = "2024"
[dependencies]

22
vendor/dn_escape/MIT_LICENSE vendored Normal file
View file

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2017 Ivan Nejgebauer <inejge@gmail.com>
Copyright (c) 2014-2017 Gregor Reitzenstein <dean4devil@paranoidlabs.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

4
vendor/dn_escape/README.md vendored Normal file
View file

@ -0,0 +1,4 @@
# dn_escape
Borrowed from the ldap3 project at version v0.12.1, under MIT license. A copy
of the license can be found in [MIT_LICENSE](MIT_LICENSE).

69
vendor/dn_escape/src/lib.rs vendored Normal file
View file

@ -0,0 +1,69 @@
// CODE DEVELOPED BY THE LDAP3 PROJECT, UNDER MIT LICENSE.
use std::borrow::Cow;
/// Escape an attribute value in a relative distinguished name (RDN).
///
/// When a literal string is used to represent an attribute value in an RDN,
/// some of its characters might need to be escaped according to the rules
/// of [RFC 4514](https://tools.ietf.org/html/rfc4514).
///
/// The function is named `dn_escape()` instead of `rdn_escape()` because of
/// a long-standing association of its intended use with the handling of DNs.
///
/// The argument, `val`, can be owned or borrowed. The function doesn't
/// allocate the return value unless there's need to escape the input.
pub fn dn_escape<'a, S: Into<Cow<'a, str>>>(val: S) -> Cow<'a, str> {
#[inline]
fn always_escape(c: u8) -> bool {
c == b'"'
|| c == b'+'
|| c == b','
|| c == b';'
|| c == b'<'
|| c == b'='
|| c == b'>'
|| c == b'\\'
|| c == 0
}
#[inline]
fn escape_leading(c: u8) -> bool {
c == b' ' || c == b'#'
}
#[inline]
fn escape_trailing(c: u8) -> bool {
c == b' '
}
#[inline]
fn xdigit(c: u8) -> u8 {
c + if c < 10 { b'0' } else { b'a' - 10 }
}
let val = val.into();
let mut output = None;
for (i, &c) in val.as_bytes().iter().enumerate() {
if always_escape(c)
|| i == 0 && escape_leading(c)
|| i + 1 == val.len() && escape_trailing(c)
{
if output.is_none() {
output = Some(Vec::with_capacity(val.len() + 12)); // guess: up to 4 escaped chars
output.as_mut().unwrap().extend(val[..i].as_bytes());
}
let output = output.as_mut().unwrap();
output.push(b'\\');
output.push(xdigit(c >> 4));
output.push(xdigit(c & 0xF));
} else if let Some(ref mut output) = output {
output.push(c);
}
}
if let Some(output) = output {
Cow::Owned(String::from_utf8(output).expect("dn escaped"))
} else {
val
}
}