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 B751791F5C for ; Thu, 9 Feb 2023 14:32:35 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 585DF24A33 for ; Thu, 9 Feb 2023 14:31:42 +0100 (CET) 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 ; Thu, 9 Feb 2023 14:31:38 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id E447A46546 for ; Thu, 9 Feb 2023 14:31:37 +0100 (CET) From: Lukas Wagner To: pbs-devel@lists.proxmox.com Date: Thu, 9 Feb 2023 14:31:16 +0100 Message-Id: <20230209133128.695211-7-l.wagner@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20230209133128.695211-1-l.wagner@proxmox.com> References: <20230209133128.695211-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.187 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [openid.rs, ldap.rs, mod.rs, domains.rs] Subject: [pbs-devel] [PATCH v3 proxmox-backup 06/18] api: add routes for managing LDAP 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: Thu, 09 Feb 2023 13:32:35 -0000 Note: bind-passwords set via the API are not stored in `domains.cfg`, but in a separate `ldap_passwords.json` file located in `/etc/proxmox-backup/`. Similar to the already existing `shadow.json`, the file is stored with 0600 permissions and is owned by root. Signed-off-by: Lukas Wagner --- pbs-config/src/domains.rs | 31 ++- src/api2/config/access/ldap.rs | 319 +++++++++++++++++++++++++++++++ src/api2/config/access/mod.rs | 7 +- src/api2/config/access/openid.rs | 5 +- src/auth_helpers.rs | 58 ++++++ 5 files changed, 405 insertions(+), 15 deletions(-) create mode 100644 src/api2/config/access/ldap.rs diff --git a/pbs-config/src/domains.rs b/pbs-config/src/domains.rs index 12d4543d..81d89d5d 100644 --- a/pbs-config/src/domains.rs +++ b/pbs-config/src/domains.rs @@ -3,35 +3,41 @@ use std::collections::HashMap; use anyhow::Error; use lazy_static::lazy_static; -use proxmox_schema::{ApiType, Schema}; +use pbs_buildcfg::configdir; +use proxmox_schema::{ApiType, ObjectSchema}; use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}; use crate::{open_backup_lockfile, replace_backup_config, BackupLockGuard}; -use pbs_api_types::{OpenIdRealmConfig, REALM_ID_SCHEMA}; +use pbs_api_types::{LdapRealmConfig, OpenIdRealmConfig, REALM_ID_SCHEMA}; lazy_static! { pub static ref CONFIG: SectionConfig = init(); } fn init() -> SectionConfig { - let obj_schema = match OpenIdRealmConfig::API_SCHEMA { - Schema::Object(ref obj_schema) => obj_schema, - _ => unreachable!(), - }; + const LDAP_SCHEMA: &ObjectSchema = LdapRealmConfig::API_SCHEMA.unwrap_object_schema(); + const OPENID_SCHEMA: &ObjectSchema = OpenIdRealmConfig::API_SCHEMA.unwrap_object_schema(); + + let mut config = SectionConfig::new(&REALM_ID_SCHEMA); let plugin = SectionConfigPlugin::new( "openid".to_string(), Some(String::from("realm")), - obj_schema, + OPENID_SCHEMA, ); - let mut config = SectionConfig::new(&REALM_ID_SCHEMA); + + config.register_plugin(plugin); + + let plugin = + SectionConfigPlugin::new("ldap".to_string(), Some(String::from("realm")), LDAP_SCHEMA); + config.register_plugin(plugin); config } -pub const DOMAINS_CFG_FILENAME: &str = "/etc/proxmox-backup/domains.cfg"; -pub const DOMAINS_CFG_LOCKFILE: &str = "/etc/proxmox-backup/.domains.lck"; +pub const DOMAINS_CFG_FILENAME: &str = configdir!("/domains.cfg"); +pub const DOMAINS_CFG_LOCKFILE: &str = configdir!("/.domains.lck"); /// Get exclusive lock pub fn lock_config() -> Result { @@ -52,6 +58,11 @@ pub fn save_config(config: &SectionConfigData) -> Result<(), Error> { replace_backup_config(DOMAINS_CFG_FILENAME, raw.as_bytes()) } +/// Check if a realm with the given name exists +pub fn exists(domains: &SectionConfigData, realm: &str) -> bool { + realm == "pbs" || realm == "pam" || domains.sections.get(realm).is_some() +} + // shell completion helper pub fn complete_realm_name(_arg: &str, _param: &HashMap) -> Vec { match config() { diff --git a/src/api2/config/access/ldap.rs b/src/api2/config/access/ldap.rs new file mode 100644 index 00000000..fa83d8ba --- /dev/null +++ b/src/api2/config/access/ldap.rs @@ -0,0 +1,319 @@ +use ::serde::{Deserialize, Serialize}; +use anyhow::Error; +use hex::FromHex; +use serde_json::Value; + +use proxmox_router::{http_bail, Permission, Router, RpcEnvironment}; +use proxmox_schema::{api, param_bail}; + +use pbs_api_types::{ + LdapRealmConfig, LdapRealmConfigUpdater, PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT, + PROXMOX_CONFIG_DIGEST_SCHEMA, REALM_ID_SCHEMA, +}; + +use pbs_config::domains; + +use crate::auth_helpers; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of configured LDAP realms.", + type: Array, + items: { type: LdapRealmConfig }, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// List configured LDAP realms +pub fn list_ldap_realms( + _param: Value, + rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + let (config, digest) = domains::config()?; + + let list = config.convert_to_typed_array("ldap")?; + + rpcenv["digest"] = hex::encode(digest).into(); + + Ok(list) +} + +#[api( + protected: true, + input: { + properties: { + config: { + type: LdapRealmConfig, + flatten: true, + }, + password: { + description: "LDAP bind password", + optional: true, + } + }, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Create a new LDAP realm +pub fn create_ldap_realm(config: LdapRealmConfig, 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); + } + + if let Some(password) = password { + auth_helpers::store_ldap_bind_password(&config.realm, &password, &domain_config_lock)?; + } + + domains.set_data(&config.realm, "ldap", &config)?; + + domains::save_config(&domains)?; + + Ok(()) +} + +#[api( + protected: true, + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Remove an LDAP realm configuration +pub fn delete_ldap_realm( + realm: String, + 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)?; + } + + if domains.sections.remove(&realm).is_none() { + http_bail!(NOT_FOUND, "realm '{}' does not exist.", realm); + } + + domains::save_config(&domains)?; + + if auth_helpers::remove_ldap_bind_password(&realm, &domain_config_lock).is_err() { + log::error!("Could not remove stored LDAP bind password for realm {realm}"); + } + + Ok(()) +} + +#[api( + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + }, + }, + returns: { type: LdapRealmConfig }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false), + }, +)] +/// Read the LDAP realm configuration +pub fn read_ldap_realm( + realm: String, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + let (domains, digest) = domains::config()?; + + let config = domains.lookup("ldap", &realm)?; + + rpcenv["digest"] = hex::encode(digest).into(); + + Ok(config) +} + +#[api()] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Deletable property name +pub enum DeletableProperty { + /// Fallback LDAP server address + Server2, + /// Port + Port, + /// Comment + Comment, + /// Verify server certificate + Verify, + /// Mode (ldap, ldap+starttls or ldaps), + Mode, + /// Bind Domain + BindDn, + /// LDAP bind passwort + Password, +} + +#[api( + protected: true, + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + update: { + type: LdapRealmConfigUpdater, + flatten: true, + }, + password: { + description: "LDAP 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: LdapRealmConfig }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Update an LDAP realm configuration +pub fn update_ldap_realm( + realm: String, + update: LdapRealmConfigUpdater, + 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: LdapRealmConfig = domains.lookup("ldap", &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)?; + } + } + } + } + + 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 = base_dn; + } + + if let Some(user_attr) = update.user_attr { + config.user_attr = user_attr; + } + + 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(password) = password { + auth_helpers::store_ldap_bind_password(&realm, &password, &domain_config_lock)?; + } + + domains.set_data(&realm, "ldap", &config)?; + + domains::save_config(&domains)?; + + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_READ_LDAP_REALM) + .put(&API_METHOD_UPDATE_LDAP_REALM) + .delete(&API_METHOD_DELETE_LDAP_REALM); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_LDAP_REALMS) + .post(&API_METHOD_CREATE_LDAP_REALM) + .match_all("realm", &ITEM_ROUTER); diff --git a/src/api2/config/access/mod.rs b/src/api2/config/access/mod.rs index a813646c..a75d89b4 100644 --- a/src/api2/config/access/mod.rs +++ b/src/api2/config/access/mod.rs @@ -2,11 +2,16 @@ use proxmox_router::list_subdirs_api_method; use proxmox_router::{Router, SubdirMap}; use proxmox_sys::sortable; +pub mod ldap; pub mod openid; pub mod tfa; #[sortable] -const SUBDIRS: SubdirMap = &sorted!([("openid", &openid::ROUTER), ("tfa", &tfa::ROUTER),]); +const SUBDIRS: SubdirMap = &sorted!([ + ("ldap", &ldap::ROUTER), + ("openid", &openid::ROUTER), + ("tfa", &tfa::ROUTER), +]); pub const ROUTER: Router = Router::new() .get(&list_subdirs_api_method!(SUBDIRS)) diff --git a/src/api2/config/access/openid.rs b/src/api2/config/access/openid.rs index bb39b574..4901880e 100644 --- a/src/api2/config/access/openid.rs +++ b/src/api2/config/access/openid.rs @@ -61,10 +61,7 @@ pub fn create_openid_realm(config: OpenIdRealmConfig) -> Result<(), Error> { let (mut domains, _digest) = domains::config()?; - if config.realm == "pbs" - || config.realm == "pam" - || domains.sections.get(&config.realm).is_some() - { + if domains::exists(&domains, &config.realm) { param_bail!("realm", "realm '{}' already exists.", config.realm); } diff --git a/src/auth_helpers.rs b/src/auth_helpers.rs index 57e02900..f4ac194a 100644 --- a/src/auth_helpers.rs +++ b/src/auth_helpers.rs @@ -6,11 +6,13 @@ use openssl::pkey::{PKey, Private, Public}; use openssl::rsa::Rsa; use openssl::sha; +use pbs_config::BackupLockGuard; use proxmox_lang::try_block; use proxmox_sys::fs::{file_get_contents, replace_file, CreateOptions}; use pbs_api_types::Userid; use pbs_buildcfg::configdir; +use serde_json::json; fn compute_csrf_secret_digest(timestamp: i64, secret: &[u8], userid: &Userid) -> String { let mut hasher = sha::Sha256::new(); @@ -180,3 +182,59 @@ pub fn private_auth_key() -> &'static PKey { &KEY } + +const LDAP_PASSWORDS_FILENAME: &str = configdir!("/ldap_passwords.json"); + +/// Store LDAP bind passwords in protected file. The domain config must be locked while this +/// function is executed. +pub fn store_ldap_bind_password( + realm: &str, + password: &str, + _domain_lock: &BackupLockGuard, +) -> Result<(), Error> { + let mut data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?; + data[realm] = password.into(); + + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0600); + let options = proxmox_sys::fs::CreateOptions::new() + .perm(mode) + .owner(nix::unistd::ROOT) + .group(nix::unistd::Gid::from_raw(0)); + + let data = serde_json::to_vec_pretty(&data)?; + proxmox_sys::fs::replace_file(LDAP_PASSWORDS_FILENAME, &data, options, true)?; + + Ok(()) +} + +/// Remove stored LDAP bind password. The domain config must be locked while this +/// function is executed. +pub fn remove_ldap_bind_password(realm: &str, _domain_lock: &BackupLockGuard) -> Result<(), Error> { + let mut data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?; + if let Some(map) = data.as_object_mut() { + map.remove(realm); + } + + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0600); + let options = proxmox_sys::fs::CreateOptions::new() + .perm(mode) + .owner(nix::unistd::ROOT) + .group(nix::unistd::Gid::from_raw(0)); + + let data = serde_json::to_vec_pretty(&data)?; + proxmox_sys::fs::replace_file(LDAP_PASSWORDS_FILENAME, &data, options, true)?; + + Ok(()) +} + +/// Retrieve stored LDAP bind password +pub 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) +} -- 2.30.2