api: access: add routes for managing AD realms

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
Tested-by: Lukas Wagner <l.wagner@proxmox.com>
This commit is contained in:
Christoph Heiss 2024-01-12 17:16:02 +01:00 committed by Thomas Lamprecht
parent b18a8170cb
commit c7051f3342
5 changed files with 528 additions and 1 deletions

98
pbs-api-types/src/ad.rs Normal file
View File

@ -0,0 +1,98 @@
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<String>,
/// AD server Port
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
/// 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<String>,
/// Comment
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
/// Connection security
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<LdapMode>,
/// Verify server certificate
#[serde(skip_serializing_if = "Option::is_none")]
pub verify: Option<bool>,
/// 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<String>,
/// Bind domain to use for looking up users
#[serde(skip_serializing_if = "Option::is_none")]
pub bind_dn: Option<String>,
/// Custom LDAP search filter for user sync
#[serde(skip_serializing_if = "Option::is_none")]
pub filter: Option<String>,
/// Default options for AD sync
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_defaults_options: Option<String>,
/// List of LDAP attributes to sync from AD to user config
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_attributes: Option<String>,
/// User ``objectClass`` classes to sync
#[serde(skip_serializing_if = "Option::is_none")]
pub user_classes: Option<String>,
}

View File

@ -130,6 +130,9 @@ pub use openid::*;
mod ldap;
pub use ldap::*;
mod ad;
pub use ad::*;
mod remote;
pub use remote::*;

View File

@ -0,0 +1,348 @@
use anyhow::{bail, format_err, Error};
use hex::FromHex;
use serde::{Deserialize, Serialize};
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::{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<Vec<AdRealmConfig>, 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<String>,
) -> 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);
conn.check_connection()
.await
.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<AdRealmConfig, Error> {
let (domains, digest) = domains::config()?;
let config = domains.lookup("ad", &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 AD 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,
/// User filter
Filter,
/// Default options for user sync
SyncDefaultsOptions,
/// user attributes to sync with AD attributes
SyncAttributes,
/// User classes
UserClasses,
}
#[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<String>,
delete: Option<Vec<DeletableProperty>>,
digest: Option<String>,
_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);
conn.check_connection()
.await
.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<String, Error> {
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);

View File

@ -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),

View File

@ -19,7 +19,9 @@ use proxmox_auth_api::Keyring;
use proxmox_ldap::{Config, Connection, ConnectionMode};
use proxmox_tfa::api::{OpenUserChallengeData, TfaConfig};
use pbs_api_types::{LdapMode, LdapRealmConfig, OpenIdRealmConfig, RealmRef, Userid, UsernameRef};
use pbs_api_types::{
AdRealmConfig, LdapMode, 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<Config, Error> {
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<String>,
) -> Result<Config, Error> {
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<Box<dyn Future<Output = Result<(), Error>> + 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,