From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <l.wagner@proxmox.com>
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 4316F93092
 for <pbs-devel@lists.proxmox.com>; Tue,  3 Jan 2023 15:23:21 +0100 (CET)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
 by firstgate.proxmox.com (Proxmox) with ESMTP id 2B16EBE00
 for <pbs-devel@lists.proxmox.com>; Tue,  3 Jan 2023 15:23:21 +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 <pbs-devel@lists.proxmox.com>; Tue,  3 Jan 2023 15:23:19 +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 8BC7F442EB
 for <pbs-devel@lists.proxmox.com>; Tue,  3 Jan 2023 15:23:19 +0100 (CET)
From: Lukas Wagner <l.wagner@proxmox.com>
To: pbs-devel@lists.proxmox.com
Date: Tue,  3 Jan 2023 15:22:56 +0100
Message-Id: <20230103142308.656240-6-l.wagner@proxmox.com>
X-Mailer: git-send-email 2.30.2
In-Reply-To: <20230103142308.656240-1-l.wagner@proxmox.com>
References: <20230103142308.656240-1-l.wagner@proxmox.com>
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit
X-SPAM-LEVEL: Spam detection results:  0
 AWL 0.184 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. [ldap.rs, mod.rs]
Subject: [pbs-devel] [PATCH proxmox-backup 05/17] auth: add LDAP module
X-BeenThere: pbs-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox Backup Server development discussion
 <pbs-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pbs-devel>, 
 <mailto:pbs-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pbs-devel/>
List-Post: <mailto:pbs-devel@lists.proxmox.com>
List-Help: <mailto:pbs-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel>, 
 <mailto:pbs-devel-request@lists.proxmox.com?subject=subscribe>
X-List-Received-Date: Tue, 03 Jan 2023 14:23:21 -0000

The module is an abstraction over the ldap3 crate. It uses
its own configuration structs to prevent strongly coupling it
to pbs-api-types.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml         |   2 +
 src/server/ldap.rs | 174 +++++++++++++++++++++++++++++++++++++++++++++
 src/server/mod.rs  |   2 +
 3 files changed, 178 insertions(+)
 create mode 100644 src/server/ldap.rs

diff --git a/Cargo.toml b/Cargo.toml
index 2639b4b1..c9f1f185 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -118,6 +118,7 @@ hex = "0.4.3"
 http = "0.2"
 hyper = { version = "0.14", features = [ "full" ] }
 lazy_static = "1.4"
+ldap3 = { version = "0.11.0-beta.1", default_features=false, features=["tls"]}
 libc = "0.2"
 log = "0.4.17"
 nix = "0.24"
@@ -169,6 +170,7 @@ hex.workspace = true
 http.workspace = true
 hyper.workspace = true
 lazy_static.workspace = true
+ldap3.workspace = true
 libc.workspace = true
 log.workspace = true
 nix.workspace = true
diff --git a/src/server/ldap.rs b/src/server/ldap.rs
new file mode 100644
index 00000000..a8b7a79d
--- /dev/null
+++ b/src/server/ldap.rs
@@ -0,0 +1,174 @@
+use std::time::Duration;
+
+use anyhow::{bail, Error};
+use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry};
+
+#[derive(PartialEq, Eq)]
+/// LDAP connection security
+pub enum LdapConnectionMode {
+    /// unencrypted connection
+    Ldap,
+    /// upgrade to TLS via STARTTLS
+    StartTls,
+    /// TLS via LDAPS
+    Ldaps,
+}
+
+/// Configuration for LDAP connections
+pub struct LdapConfig {
+    /// Array of servers that will be tried in order
+    pub servers: Vec<String>,
+    /// Port
+    pub port: Option<u16>,
+    /// 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<String>,
+    /// LDAP bind password, will be used for user lookup/sync if set
+    pub bind_password: Option<String>,
+    /// Connection security
+    pub tls_mode: LdapConnectionMode,
+    /// Verify the server's TLS certificate
+    pub verify_certificate: bool,
+}
+
+pub struct LdapConnection {
+    config: LdapConfig,
+}
+
+impl LdapConnection {
+    const LDAP_DEFAULT_PORT: u16 = 389;
+    const LDAPS_DEFAULT_PORT: u16 = 636;
+    const LDAP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
+
+    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 appropriate 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}")
+    }
+
+    async fn try_connect(&self, url: &str) -> Result<(LdapConnAsync, Ldap), Error> {
+        let starttls = self.config.tls_mode == LdapConnectionMode::StartTls;
+
+        LdapConnAsync::with_settings(
+            LdapConnSettings::new()
+                .set_no_tls_verify(!self.config.verify_certificate)
+                .set_starttls(starttls)
+                .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT),
+            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<Ldap, Error> {
+        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<String, Error> {
+        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<String, Error> {
+        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")
+    }
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 06dcb867..649c1c51 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -13,6 +13,8 @@ use pbs_buildcfg;
 
 pub mod jobstate;
 
+pub mod ldap;
+
 mod verify_job;
 pub use verify_job::*;
 
-- 
2.30.2