diff --git a/Cargo.toml b/Cargo.toml index 91291f78..54891536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,8 +49,10 @@ hex = "0.4" http = "0.2" hyper = "0.14.5" lazy_static = "1.4" +ldap3 = "0.11" libc = "0.2.107" log = "0.4.17" +native-tls = "0.2" nix = "0.26.1" once_cell = "1.3.1" openssl = "0.10" diff --git a/proxmox-ldap/Cargo.toml b/proxmox-ldap/Cargo.toml index 00358fc4..70fba732 100644 --- a/proxmox-ldap/Cargo.toml +++ b/proxmox-ldap/Cargo.toml @@ -9,3 +9,8 @@ exclude.workspace = true description = "Proxmox library for LDAP authentication/synchronization" [dependencies] +anyhow.workspace = true +ldap3 = { workspace = true, default_features = false, features = ["tls"] } +serde = { workspace = true, features = ["derive"] } +native-tls.workspace = true + diff --git a/proxmox-ldap/src/lib.rs b/proxmox-ldap/src/lib.rs index e69de29b..48eb8636 100644 --- a/proxmox-ldap/src/lib.rs +++ b/proxmox-ldap/src/lib.rs @@ -0,0 +1,228 @@ +use std::{ + fs, + path::{Path, PathBuf}, + time::Duration, +}; + +use anyhow::{bail, Error}; +use ldap3::{ + Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry, +}; +use native_tls::{Certificate, TlsConnector, TlsConnectorBuilder}; +use serde::{Deserialize, Serialize}; + +#[derive(PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Debug)] +/// LDAP connection security +pub enum LdapConnectionMode { + /// unencrypted connection + Ldap, + /// upgrade to TLS via STARTTLS + StartTls, + /// TLS via LDAPS + Ldaps, +} + +#[derive(Clone, Serialize, Deserialize)] +/// Configuration for LDAP connections +pub struct LdapConfig { + /// Array of servers that will be tried in order + pub servers: Vec, + /// Port + pub port: Option, + /// LDAP attribute containing the user id. Will be used to look up the user's domain + pub user_attr: String, + /// LDAP base domain + pub base_dn: String, + /// LDAP bind domain, will be used for user lookup/sync if set + pub bind_dn: Option, + /// LDAP bind password, will be used for user lookup/sync if set + pub bind_password: Option, + /// Connection security + pub tls_mode: LdapConnectionMode, + /// Verify the server's TLS certificate + pub verify_certificate: bool, + /// Root certificates that should be trusted, in addition to + /// the ones from the certificate store. + /// Expects X.509 certs in PEM format. + pub additional_trusted_certificates: Option>, + /// Override the path to the system's default certificate store + /// in /etc/ssl/certs (added for PVE compatibility) + pub certificate_store_path: Option, +} + +/// Connection to an LDAP server, can be used to authenticate users. +pub struct LdapConnection { + /// Configuration for this connection + config: LdapConfig, +} + +impl LdapConnection { + /// Default port for LDAP/StartTls connections + const LDAP_DEFAULT_PORT: u16 = 389; + /// Default port for LDAPS connections + const LDAPS_DEFAULT_PORT: u16 = 636; + /// Connection timeout + const LDAP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); + + /// Create a new LDAP connection. + pub fn new(config: LdapConfig) -> Self { + Self { config } + } + + /// Authenticate a user with username/password. + /// + /// The user's domain is queried is by performing an LDAP search with the configured bind_dn + /// and bind_password. If no bind_dn is provided, an anonymous search is attempted. + pub async fn authenticate_user(&self, username: &str, password: &str) -> Result<(), Error> { + let user_dn = self.search_user_dn(username).await?; + + let mut ldap = self.create_connection().await?; + + // Perform actual user authentication by binding. + let _: LdapResult = ldap.simple_bind(&user_dn, password).await?.success()?; + + // We are already authenticated, so don't fail if terminating the connection + // does not work for some reason. + let _: Result<(), _> = ldap.unbind().await; + + Ok(()) + } + + /// Retrive port from LDAP configuration, otherwise use the correct default + fn port_from_config(&self) -> u16 { + self.config.port.unwrap_or_else(|| { + if self.config.tls_mode == LdapConnectionMode::Ldaps { + Self::LDAPS_DEFAULT_PORT + } else { + Self::LDAP_DEFAULT_PORT + } + }) + } + + /// Determine correct URL scheme from LDAP config + fn scheme_from_config(&self) -> &'static str { + if self.config.tls_mode == LdapConnectionMode::Ldaps { + "ldaps" + } else { + "ldap" + } + } + + /// Construct URL from LDAP config + fn ldap_url_from_config(&self, server: &str) -> String { + let port = self.port_from_config(); + let scheme = self.scheme_from_config(); + format!("{scheme}://{server}:{port}") + } + + fn add_cert_to_builder>( + path: P, + builder: &mut TlsConnectorBuilder, + ) -> Result<(), Error> { + let bytes = fs::read(path)?; + let cert = Certificate::from_pem(&bytes)?; + builder.add_root_certificate(cert); + + Ok(()) + } + + async fn try_connect(&self, url: &str) -> Result<(LdapConnAsync, Ldap), Error> { + let starttls = self.config.tls_mode == LdapConnectionMode::StartTls; + + let mut builder = TlsConnector::builder(); + builder.danger_accept_invalid_certs(!self.config.verify_certificate); + + if let Some(certificate_paths) = self.config.additional_trusted_certificates.as_deref() { + for path in certificate_paths { + Self::add_cert_to_builder(path, &mut builder)?; + } + } + + if let Some(certificate_store_path) = self.config.certificate_store_path.as_deref() { + builder.disable_built_in_roots(true); + + for dir_entry in fs::read_dir(certificate_store_path)? { + let dir_entry = dir_entry?; + + if !dir_entry.metadata()?.is_dir() { + Self::add_cert_to_builder(dir_entry.path(), &mut builder)?; + } + } + } + + LdapConnAsync::with_settings( + LdapConnSettings::new() + .set_starttls(starttls) + .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT) + .set_connector(builder.build()?), + url, + ) + .await + .map_err(|e| e.into()) + } + + /// Create LDAP connection + /// + /// If a connection to the server cannot be established, the fallbacks + /// are tried. + async fn create_connection(&self) -> Result { + let mut last_error = None; + + for server in &self.config.servers { + match self.try_connect(&self.ldap_url_from_config(server)).await { + Ok((connection, ldap)) => { + ldap3::drive!(connection); + return Ok(ldap); + } + Err(e) => { + last_error = Some(e); + } + } + } + + Err(last_error.unwrap()) + } + + /// Search a user's domain. + async fn search_user_dn(&self, username: &str) -> Result { + let mut ldap = self.create_connection().await?; + + if let Some(bind_dn) = self.config.bind_dn.as_deref() { + let password = self.config.bind_password.as_deref().unwrap_or_default(); + let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?; + + let user_dn = self.do_search_user_dn(username, &mut ldap).await; + + ldap.unbind().await?; + + user_dn + } else { + self.do_search_user_dn(username, &mut ldap).await + } + } + + async fn do_search_user_dn(&self, username: &str, ldap: &mut Ldap) -> Result { + let query = format!("(&({}={}))", self.config.user_attr, username); + + let (entries, _res) = ldap + .search(&self.config.base_dn, Scope::Subtree, &query, vec!["dn"]) + .await? + .success()?; + + if entries.len() > 1 { + bail!( + "found multiple users with attribute `{}={}`", + self.config.user_attr, + username + ) + } + + if let Some(entry) = entries.into_iter().next() { + let entry = SearchEntry::construct(entry); + + return Ok(entry.dn); + } + + bail!("user not found") + } +}