From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 798DBB07B for ; Tue, 8 Aug 2023 14:23:26 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id B0E979BE1 for ; Tue, 8 Aug 2023 14:22:54 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Tue, 8 Aug 2023 14:22:52 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 152AC437C8 for ; Tue, 8 Aug 2023 14:22:52 +0200 (CEST) From: Christoph Heiss To: pbs-devel@lists.proxmox.com Date: Tue, 8 Aug 2023 14:22:09 +0200 Message-ID: <20230808122239.1025524-8-c.heiss@proxmox.com> X-Mailer: git-send-email 2.41.0 In-Reply-To: <20230808122239.1025524-1-c.heiss@proxmox.com> References: <20230808122239.1025524-1-c.heiss@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.046 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: [pbs-devel] [PATCH proxmox-backup 07/12] api: access: add routes for managing AD realms X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 08 Aug 2023 12:23:26 -0000 Signed-off-by: Christoph Heiss --- pbs-api-types/src/ad.rs | 101 +++++++++++ pbs-api-types/src/lib.rs | 3 + src/api2/config/access/ad.rs | 314 ++++++++++++++++++++++++++++++++++ src/api2/config/access/mod.rs | 2 + src/auth.rs | 78 ++++++++- 5 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 pbs-api-types/src/ad.rs create mode 100644 src/api2/config/access/ad.rs diff --git a/pbs-api-types/src/ad.rs b/pbs-api-types/src/ad.rs new file mode 100644 index 00000000..446715c7 --- /dev/null +++ b/pbs-api-types/src/ad.rs @@ -0,0 +1,101 @@ +use serde::{Deserialize, Serialize}; + +use proxmox_schema::{api, Updater}; + +use super::{ + LdapMode, LDAP_DOMAIN_SCHEMA, REALM_ID_SCHEMA, SINGLE_LINE_COMMENT_SCHEMA, + SYNC_ATTRIBUTES_SCHEMA, SYNC_DEFAULTS_STRING_SCHEMA, USER_CLASSES_SCHEMA, +}; + +#[api( + properties: { + "realm": { + schema: REALM_ID_SCHEMA, + }, + "comment": { + optional: true, + schema: SINGLE_LINE_COMMENT_SCHEMA, + }, + "verify": { + optional: true, + default: false, + }, + "sync-defaults-options": { + schema: SYNC_DEFAULTS_STRING_SCHEMA, + optional: true, + }, + "sync-attributes": { + schema: SYNC_ATTRIBUTES_SCHEMA, + optional: true, + }, + "user-classes" : { + optional: true, + schema: USER_CLASSES_SCHEMA, + }, + "base-dn" : { + schema: LDAP_DOMAIN_SCHEMA, + optional: true, + }, + "bind-dn" : { + schema: LDAP_DOMAIN_SCHEMA, + optional: true, + } + }, +)] +#[derive(Serialize, Deserialize, Updater, Clone)] +#[serde(rename_all = "kebab-case")] +/// AD realm configuration properties. +pub struct AdRealmConfig { + #[updater(skip)] + pub realm: String, + /// AD server address + pub server1: String, + /// Fallback AD server address + #[serde(skip_serializing_if = "Option::is_none")] + pub server2: Option, + /// AD server Port + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + /// Base domain name. Users are searched under this domain using a `subtree search`. + /// Expected to be set only internally to `defaultNamingContext` of the AD server, but can be + /// overridden if the need arises. + #[serde(skip_serializing_if = "Option::is_none")] + pub base_dn: Option, + /// Whether usernames should be matched case-sensitive + #[serde(skip_serializing_if = "Option::is_none")] + pub case_sensitive: Option, + /// Comment + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, + /// Connection security + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// Verify server certificate + #[serde(skip_serializing_if = "Option::is_none")] + pub verify: Option, + /// CA certificate to use for the server. The path can point to + /// either a file, or a directory. If it points to a file, + /// the PEM-formatted X.509 certificate stored at the path + /// will be added as a trusted certificate. + /// If the path points to a directory, + /// the directory replaces the system's default certificate + /// store at `/etc/ssl/certs` - Every file in the directory + /// will be loaded as a trusted certificate. + #[serde(skip_serializing_if = "Option::is_none")] + pub capath: Option, + /// Bind domain to use for looking up users + #[serde(skip_serializing_if = "Option::is_none")] + pub bind_dn: Option, + /// Custom LDAP search filter for user sync + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option, + /// Default options for AD sync + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_defaults_options: Option, + /// List of LDAP attributes to sync from AD to user config + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_attributes: Option, + /// User ``objectClass`` classes to sync + #[serde(skip_serializing_if = "Option::is_none")] + pub user_classes: Option, +} diff --git a/pbs-api-types/src/lib.rs b/pbs-api-types/src/lib.rs index 6ebbe514..c622484e 100644 --- a/pbs-api-types/src/lib.rs +++ b/pbs-api-types/src/lib.rs @@ -114,6 +114,9 @@ pub use openid::*; mod ldap; pub use ldap::*; +mod ad; +pub use ad::*; + mod remote; pub use remote::*; diff --git a/src/api2/config/access/ad.rs b/src/api2/config/access/ad.rs new file mode 100644 index 00000000..0803cdfb --- /dev/null +++ b/src/api2/config/access/ad.rs @@ -0,0 +1,314 @@ +use anyhow::{bail, format_err, Error}; +use hex::FromHex; +use serde_json::Value; + +use proxmox_ldap::{Config as LdapConfig, Connection}; +use proxmox_router::{Permission, Router, RpcEnvironment}; +use proxmox_schema::{api, param_bail}; + +use pbs_api_types::{ + AdRealmConfig, AdRealmConfigUpdater, PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT, + PROXMOX_CONFIG_DIGEST_SCHEMA, REALM_ID_SCHEMA, +}; + +use pbs_config::domains; + +use crate::{api2::config::access::ldap::DeletableProperty, auth::AdAuthenticator, auth_helpers}; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of configured AD realms.", + type: Array, + items: { type: AdRealmConfig }, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// List configured AD realms +pub fn list_ad_realms( + _param: Value, + rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + let (config, digest) = domains::config()?; + + let list = config.convert_to_typed_array("ad")?; + + rpcenv["digest"] = hex::encode(digest).into(); + + Ok(list) +} + +#[api( + protected: true, + input: { + properties: { + config: { + type: AdRealmConfig, + flatten: true, + }, + password: { + description: "AD bind password", + optional: true, + } + }, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Create a new AD realm +pub async fn create_ad_realm( + mut config: AdRealmConfig, + password: Option, +) -> Result<(), Error> { + let domain_config_lock = domains::lock_config()?; + + let (mut domains, _digest) = domains::config()?; + + if domains::exists(&domains, &config.realm) { + param_bail!("realm", "realm '{}' already exists.", config.realm); + } + + let mut ldap_config = + AdAuthenticator::api_type_to_config_with_password(&config, password.clone())?; + + if config.base_dn.is_none() { + ldap_config.base_dn = retrieve_default_naming_context(&ldap_config).await?; + config.base_dn = Some(ldap_config.base_dn.clone()); + } + + let conn = Connection::new(ldap_config); + proxmox_async::runtime::block_on(conn.check_connection()).map_err(|e| format_err!("{e:#}"))?; + + if let Some(password) = password { + auth_helpers::store_ldap_bind_password(&config.realm, &password, &domain_config_lock)?; + } + + domains.set_data(&config.realm, "ad", &config)?; + + domains::save_config(&domains)?; + + Ok(()) +} + +#[api( + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + }, + }, + returns: { type: AdRealmConfig }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false), + }, +)] +/// Read the AD realm configuration +pub fn read_ad_realm( + realm: String, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + let (domains, digest) = domains::config()?; + + let config = domains.lookup("ad", &realm)?; + + rpcenv["digest"] = hex::encode(digest).into(); + + Ok(config) +} + +#[api( + protected: true, + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + update: { + type: AdRealmConfigUpdater, + flatten: true, + }, + password: { + description: "AD bind password", + optional: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeletableProperty, + } + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, + returns: { type: AdRealmConfig }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Update an AD realm configuration +pub async fn update_ad_realm( + realm: String, + update: AdRealmConfigUpdater, + password: Option, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let domain_config_lock = domains::lock_config()?; + + let (mut domains, expected_digest) = domains::config()?; + + if let Some(ref digest) = digest { + let digest = <[u8; 32]>::from_hex(digest)?; + crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?; + } + + let mut config: AdRealmConfig = domains.lookup("ad", &realm)?; + + if let Some(delete) = delete { + for delete_prop in delete { + match delete_prop { + DeletableProperty::Server2 => { + config.server2 = None; + } + DeletableProperty::Comment => { + config.comment = None; + } + DeletableProperty::Port => { + config.port = None; + } + DeletableProperty::Verify => { + config.verify = None; + } + DeletableProperty::Mode => { + config.mode = None; + } + DeletableProperty::BindDn => { + config.bind_dn = None; + } + DeletableProperty::Password => { + auth_helpers::remove_ldap_bind_password(&realm, &domain_config_lock)?; + } + DeletableProperty::Filter => { + config.filter = None; + } + DeletableProperty::SyncDefaultsOptions => { + config.sync_defaults_options = None; + } + DeletableProperty::SyncAttributes => { + config.sync_attributes = None; + } + DeletableProperty::UserClasses => { + config.user_classes = None; + } + } + } + } + + if let Some(server1) = update.server1 { + config.server1 = server1; + } + + if let Some(server2) = update.server2 { + config.server2 = Some(server2); + } + + if let Some(port) = update.port { + config.port = Some(port); + } + + if let Some(base_dn) = update.base_dn { + config.base_dn = Some(base_dn); + } + + if let Some(comment) = update.comment { + let comment = comment.trim().to_string(); + if comment.is_empty() { + config.comment = None; + } else { + config.comment = Some(comment); + } + } + + if let Some(mode) = update.mode { + config.mode = Some(mode); + } + + if let Some(verify) = update.verify { + config.verify = Some(verify); + } + + if let Some(bind_dn) = update.bind_dn { + config.bind_dn = Some(bind_dn); + } + + if let Some(filter) = update.filter { + config.filter = Some(filter); + } + + if let Some(sync_defaults_options) = update.sync_defaults_options { + config.sync_defaults_options = Some(sync_defaults_options); + } + + if let Some(sync_attributes) = update.sync_attributes { + config.sync_attributes = Some(sync_attributes); + } + + if let Some(user_classes) = update.user_classes { + config.user_classes = Some(user_classes); + } + + let mut ldap_config = if password.is_some() { + AdAuthenticator::api_type_to_config_with_password(&config, password.clone())? + } else { + AdAuthenticator::api_type_to_config(&config)? + }; + + if config.base_dn.is_none() { + ldap_config.base_dn = retrieve_default_naming_context(&ldap_config).await?; + config.base_dn = Some(ldap_config.base_dn.clone()); + } + + let conn = Connection::new(ldap_config); + proxmox_async::runtime::block_on(conn.check_connection()).map_err(|e| format_err!("{e:#}"))?; + + if let Some(password) = password { + auth_helpers::store_ldap_bind_password(&realm, &password, &domain_config_lock)?; + } + + domains.set_data(&realm, "ad", &config)?; + + domains::save_config(&domains)?; + + Ok(()) +} + +async fn retrieve_default_naming_context(ldap_config: &LdapConfig) -> Result { + let conn = Connection::new(ldap_config.clone()); + match conn.retrieve_root_dse_attr("defaultNamingContext").await { + Ok(base_dn) if !base_dn.is_empty() => Ok(base_dn[0].clone()), + Ok(_) => bail!("server did not provide `defaultNamingContext`"), + Err(err) => bail!("failed to determine base_dn: {err}"), + } +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_READ_AD_REALM) + .put(&API_METHOD_UPDATE_AD_REALM) + .delete(&super::ldap::API_METHOD_DELETE_LDAP_REALM); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_AD_REALMS) + .post(&API_METHOD_CREATE_AD_REALM) + .match_all("realm", &ITEM_ROUTER); diff --git a/src/api2/config/access/mod.rs b/src/api2/config/access/mod.rs index 614bd5e6..b551e662 100644 --- a/src/api2/config/access/mod.rs +++ b/src/api2/config/access/mod.rs @@ -2,12 +2,14 @@ use proxmox_router::list_subdirs_api_method; use proxmox_router::{Router, SubdirMap}; use proxmox_sortable_macro::sortable; +pub mod ad; pub mod ldap; pub mod openid; pub mod tfa; #[sortable] const SUBDIRS: SubdirMap = &sorted!([ + ("ad", &ad::ROUTER), ("ldap", &ldap::ROUTER), ("openid", &openid::ROUTER), ("tfa", &tfa::ROUTER), diff --git a/src/auth.rs b/src/auth.rs index ae6ff729..ce234990 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -19,7 +19,9 @@ use proxmox_auth_api::Keyring; use proxmox_ldap::{Config, Connection}; use proxmox_tfa::api::{OpenUserChallengeData, TfaConfig}; -use pbs_api_types::{LdapRealmConfig, OpenIdRealmConfig, RealmRef, Userid, UsernameRef}; +use pbs_api_types::{ + AdRealmConfig, LdapRealmConfig, OpenIdRealmConfig, RealmRef, Userid, UsernameRef, +}; use pbs_buildcfg::configdir; use crate::auth_helpers; @@ -202,6 +204,80 @@ impl LdapAuthenticator { } } +pub struct AdAuthenticator { + config: AdRealmConfig, +} + +impl AdAuthenticator { + pub fn api_type_to_config(config: &AdRealmConfig) -> Result { + Self::api_type_to_config_with_password( + config, + auth_helpers::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: config.mode.unwrap_or_default().into(), + 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 lookup_ca_store_or_cert_path(capath: Option<&str>) -> (Option, Option>) { if let Some(capath) = capath { let path = PathBuf::from(capath); -- 2.41.0