From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 564CE1FF16F for ; Tue, 16 Sep 2025 16:48:40 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8C7F417E56; Tue, 16 Sep 2025 16:48:39 +0200 (CEST) From: Shannon Sterz To: pdm-devel@lists.proxmox.com Date: Tue, 16 Sep 2025 16:48:24 +0200 Message-ID: <20250916144827.551806-9-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20250916144827.551806-1-s.sterz@proxmox.com> References: <20250916144827.551806-1-s.sterz@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1758034104061 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.049 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH datacenter-manager 2/5] server: add ldap and active directory authenticators X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" so that these types of realms could be used to login. Signed-off-by: Shannon Sterz --- lib/pdm-api-types/src/lib.rs | 7 ++ server/Cargo.toml | 1 + server/src/auth/ldap.rs | 202 +++++++++++++++++++++++++++++++++++ server/src/auth/mod.rs | 17 ++- 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 server/src/auth/ldap.rs diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs index a7eaa0d..a356614 100644 --- a/lib/pdm-api-types/src/lib.rs +++ b/lib/pdm-api-types/src/lib.rs @@ -349,8 +349,15 @@ pub enum RealmType { Pdm, /// An OpenID Connect realm OpenId, + /// An Active Directory realm + Ad, + /// An LDAP realm + Ldap, } +serde_plain::derive_display_from_serialize!(RealmType); +serde_plain::derive_fromstr_from_deserialize!(RealmType); + #[api( properties: { realm: { diff --git a/server/Cargo.toml b/server/Cargo.toml index 9eefa0f..0dfcb6c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -41,6 +41,7 @@ proxmox-base64.workspace = true proxmox-daemon.workspace = true proxmox-http = { workspace = true, features = [ "client-trait", "proxmox-async" ] } # pbs-client doesn't use these proxmox-lang.workspace = true +proxmox-ldap.workspace = true proxmox-log.workspace = true proxmox-login.workspace = true proxmox-rest-server = { workspace = true, features = [ "templates" ] } diff --git a/server/src/auth/ldap.rs b/server/src/auth/ldap.rs new file mode 100644 index 0000000..8f2e57e --- /dev/null +++ b/server/src/auth/ldap.rs @@ -0,0 +1,202 @@ +use std::future::Future; +use std::net::IpAddr; +use std::path::PathBuf; +use std::pin::Pin; + +use anyhow::Error; +use pdm_buildcfg::configdir; +use proxmox_auth_api::api::Authenticator; +use proxmox_ldap::types::{AdRealmConfig, LdapMode, LdapRealmConfig}; +use proxmox_ldap::{Config, Connection, ConnectionMode}; +use proxmox_router::http_bail; +use serde_json::json; + +use pdm_api_types::UsernameRef; + +const LDAP_PASSWORDS_FILENAME: &str = configdir!("/ldap_passwords.json"); + +#[allow(clippy::upper_case_acronyms)] +pub(crate) struct LdapAuthenticator { + config: LdapRealmConfig, +} + +impl LdapAuthenticator { + pub(crate) fn new(config: LdapRealmConfig) -> Self { + Self { config } + } +} + +impl Authenticator for LdapAuthenticator { + /// Authenticate user in LDAP realm + fn authenticate_user<'a>( + &'a self, + username: &'a UsernameRef, + password: &'a str, + _client_ip: Option<&'a IpAddr>, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let ldap_config = Self::api_type_to_config(&self.config)?; + let ldap = Connection::new(ldap_config); + ldap.authenticate_user(username.as_str(), password).await?; + Ok(()) + }) + } + + fn store_password( + &self, + _username: &UsernameRef, + _password: &str, + _client_ip: Option<&IpAddr>, + ) -> Result<(), Error> { + http_bail!( + NOT_IMPLEMENTED, + "storing passwords is not implemented for LDAP realms" + ); + } + + fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> { + http_bail!( + NOT_IMPLEMENTED, + "removing passwords is not implemented for LDAP realms" + ); + } +} + +impl LdapAuthenticator { + pub fn api_type_to_config(config: &LdapRealmConfig) -> Result { + Self::api_type_to_config_with_password(config, get_ldap_bind_password(&config.realm)?) + } + + pub fn api_type_to_config_with_password( + config: &LdapRealmConfig, + password: Option, + ) -> Result { + let mut servers = vec![config.server1.clone()]; + if let Some(server) = &config.server2 { + servers.push(server.clone()); + } + + let (ca_store, trusted_cert) = lookup_ca_store_or_cert_path(config.capath.as_deref()); + + Ok(Config { + servers, + port: config.port, + user_attr: config.user_attr.clone(), + base_dn: config.base_dn.clone(), + bind_dn: config.bind_dn.clone(), + bind_password: password, + tls_mode: ldap_to_conn_mode(config.mode.unwrap_or_default()), + verify_certificate: config.verify.unwrap_or_default(), + additional_trusted_certificates: trusted_cert, + certificate_store_path: ca_store, + }) + } +} + +pub struct AdAuthenticator { + config: AdRealmConfig, +} + +impl AdAuthenticator { + pub(crate) fn new(config: AdRealmConfig) -> Self { + Self { config } + } + + pub fn api_type_to_config(config: &AdRealmConfig) -> Result { + Self::api_type_to_config_with_password(config, get_ldap_bind_password(&config.realm)?) + } + + pub fn api_type_to_config_with_password( + config: &AdRealmConfig, + password: Option, + ) -> Result { + let mut servers = vec![config.server1.clone()]; + if let Some(server) = &config.server2 { + servers.push(server.clone()); + } + + let (ca_store, trusted_cert) = lookup_ca_store_or_cert_path(config.capath.as_deref()); + + Ok(Config { + servers, + port: config.port, + user_attr: "sAMAccountName".to_owned(), + base_dn: config.base_dn.clone().unwrap_or_default(), + bind_dn: config.bind_dn.clone(), + bind_password: password, + tls_mode: ldap_to_conn_mode(config.mode.unwrap_or_default()), + verify_certificate: config.verify.unwrap_or_default(), + additional_trusted_certificates: trusted_cert, + certificate_store_path: ca_store, + }) + } +} + +impl Authenticator for AdAuthenticator { + /// Authenticate user in AD realm + fn authenticate_user<'a>( + &'a self, + username: &'a UsernameRef, + password: &'a str, + _client_ip: Option<&'a IpAddr>, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let ldap_config = Self::api_type_to_config(&self.config)?; + let ldap = Connection::new(ldap_config); + ldap.authenticate_user(username.as_str(), password).await?; + Ok(()) + }) + } + + fn store_password( + &self, + _username: &UsernameRef, + _password: &str, + _client_ip: Option<&IpAddr>, + ) -> Result<(), Error> { + http_bail!( + NOT_IMPLEMENTED, + "storing passwords is not implemented for Active Directory realms" + ); + } + + fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> { + http_bail!( + NOT_IMPLEMENTED, + "removing passwords is not implemented for Active Directory realms" + ); + } +} + +fn ldap_to_conn_mode(mode: LdapMode) -> ConnectionMode { + match mode { + LdapMode::Ldap => ConnectionMode::Ldap, + LdapMode::StartTls => ConnectionMode::StartTls, + LdapMode::Ldaps => ConnectionMode::Ldaps, + } +} + +fn lookup_ca_store_or_cert_path(capath: Option<&str>) -> (Option, Option>) { + if let Some(capath) = capath { + let path = PathBuf::from(capath); + if path.is_dir() { + (Some(path), None) + } else { + (None, Some(vec![path])) + } + } else { + (None, None) + } +} + +/// Retrieve stored LDAP bind password +pub(super) fn get_ldap_bind_password(realm: &str) -> Result, Error> { + let data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?; + + let password = data + .get(realm) + .and_then(|s| s.as_str()) + .map(|s| s.to_owned()); + + Ok(password) +} diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index a0e0a34..532350d 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -8,11 +8,13 @@ use std::sync::OnceLock; use anyhow::{bail, Error}; use const_format::concatcp; +use ldap::{AdAuthenticator, LdapAuthenticator}; use proxmox_access_control::CachedUserInfo; use proxmox_auth_api::api::{Authenticator, LockedTfaConfig}; use proxmox_auth_api::ticket::Ticket; use proxmox_auth_api::types::Authid; use proxmox_auth_api::{HMACKey, Keyring}; +use proxmox_ldap::types::{AdRealmConfig, LdapRealmConfig}; use proxmox_rest_server::AuthError; use proxmox_router::UserInformation; use proxmox_tfa::api::{OpenUserChallengeData, TfaConfig}; @@ -22,6 +24,7 @@ use pdm_api_types::{RealmRef, Userid}; pub mod certs; pub mod csrf; pub mod key; +pub(crate) mod ldap; pub mod tfa; pub const TERM_PREFIX: &str = "PDMTERM"; @@ -182,7 +185,19 @@ pub(crate) fn lookup_authenticator( config_filename: pdm_buildcfg::configdir!("/access/shadow.json"), lock_filename: pdm_buildcfg::configdir!("/access/shadow.json.lock"), })), - realm => bail!("unknown realm '{}'", realm), + realm => { + if let Ok((domains, _digest)) = pdm_config::domains::config() { + if let Ok(config) = domains.lookup::("ldap", realm) { + return Ok(Box::new(LdapAuthenticator::new(config))); + } + + if let Ok(config) = domains.lookup::("ad", realm) { + return Ok(Box::new(AdAuthenticator::new(config))); + } + } + + bail!("unknwon realm {realm}"); + } } } -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel