From cda2bcacc39f9039a85f7f8e9f0b56341631a671 Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Tue, 16 Mar 2021 18:27:31 +0100 Subject: [PATCH] Add ability to list users with an LDAP search request --- Cargo.toml | 1 + src/domain/handler.rs | 23 ++- src/infra/configuration.rs | 2 + src/infra/ldap_handler.rs | 286 ++++++++++++++++++++++++++++++++----- src/infra/ldap_server.rs | 5 +- 5 files changed, 278 insertions(+), 39 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3014aca..2d7bea5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ actix-service = "2.0.0-beta.4" actix-web = "4.0.0-beta.3" anyhow = "*" clap = "3.0.0-beta.2" +chrono = "*" futures = "*" futures-util = "*" http = "*" diff --git a/src/domain/handler.rs b/src/domain/handler.rs index 582a691..5442389 100644 --- a/src/domain/handler.rs +++ b/src/domain/handler.rs @@ -9,14 +9,25 @@ pub struct BindRequest { } #[cfg_attr(test, derive(PartialEq, Eq, Debug))] -pub struct SearchRequest {} +pub struct ListUsersRequest { + // filters + pub attrs: Vec, +} #[cfg_attr(test, derive(PartialEq, Eq, Debug))] -pub struct SearchResponse {} +pub struct User { + pub user_id: String, + pub email: String, + pub display_name: String, + pub first_name: String, + pub last_name: String, + // pub avatar: ?, + pub creation_date: chrono::NaiveDateTime, +} pub trait BackendHandler: Clone + Send { fn bind(&mut self, request: BindRequest) -> Result<()>; - fn search(&mut self, request: SearchRequest) -> Result; + fn list_users(&mut self, request: ListUsersRequest) -> Result>; } #[derive(Debug, Clone)] @@ -48,8 +59,8 @@ impl BackendHandler for SqlBackendHandler { } } - fn search(&mut self, request: SearchRequest) -> Result { - Ok(SearchResponse {}) + fn list_users(&mut self, request: ListUsersRequest) -> Result> { + Ok(Vec::new()) } } @@ -61,6 +72,6 @@ mockall::mock! { } impl BackendHandler for TestBackendHandler { fn bind(&mut self, request: BindRequest) -> Result<()>; - fn search(&mut self, request: SearchRequest) -> Result; + fn list_users(&mut self, request: ListUsersRequest) -> Result>; } } diff --git a/src/infra/configuration.rs b/src/infra/configuration.rs index 7e38d81..42269d4 100644 --- a/src/infra/configuration.rs +++ b/src/infra/configuration.rs @@ -13,6 +13,7 @@ pub struct Configuration { pub ldaps_port: u16, pub http_port: u16, pub secret_pepper: String, + pub ldap_base_dn: String, pub ldap_user_dn: String, pub ldap_user_pass: String, pub database_url: String, @@ -26,6 +27,7 @@ impl Default for Configuration { ldaps_port: 6360, http_port: 17170, secret_pepper: String::from("secretsecretpepper"), + ldap_base_dn: String::from("dc=example,dc=com"), ldap_user_dn: String::new(), ldap_user_pass: String::new(), database_url: String::from("sqlite://users.db?mode=rwc"), diff --git a/src/infra/ldap_handler.rs b/src/infra/ldap_handler.rs index a7ca3dc..26ff7e5 100644 --- a/src/infra/ldap_handler.rs +++ b/src/infra/ldap_handler.rs @@ -1,16 +1,102 @@ -use crate::domain::handler::BackendHandler; +use crate::domain::handler::{BackendHandler, ListUsersRequest, User}; +use anyhow::{bail, Result}; use ldap3_server::simple::*; +fn make_dn_pair<'a, I>(mut iter: I) -> Result<(String, String)> +where + I: Iterator, +{ + let pair = ( + iter.next() + .ok_or(anyhow::Error::msg("Empty DN element"))? + .clone(), + iter.next() + .ok_or(anyhow::Error::msg("Missing DN value"))? + .clone(), + ); + if let Some(e) = iter.next() { + bail!( + r#"Too many elements in distinguished name: "{:?}", "{:?}", "{:?}""#, + pair.0, + pair.1, + e + ) + } + Ok(pair) +} + +fn parse_distinguished_name(dn: &str) -> Result> { + dn.split(",") + .map(|s| make_dn_pair(s.split("=").map(String::from))) + .collect() +} + +fn make_ldap_search_result_entry(user: User, base_dn_str: &str) -> LdapSearchResultEntry { + LdapSearchResultEntry { + dn: format!("cn={},{}", user.user_id, base_dn_str), + attributes: vec![ + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![ + "inetOrgPerson".to_string(), + "posixAccount".to_string(), + "mailAccount".to_string(), + ], + }, + LdapPartialAttribute { + atype: "uid".to_string(), + vals: vec![user.user_id], + }, + LdapPartialAttribute { + atype: "mail".to_string(), + vals: vec![user.email], + }, + LdapPartialAttribute { + atype: "givenName".to_string(), + vals: vec![user.first_name], + }, + LdapPartialAttribute { + atype: "sn".to_string(), + vals: vec![user.last_name], + }, + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![user.display_name], + }, + ], + } +} + +fn is_subtree(subtree: &Vec<(String, String)>, base_tree: &Vec<(String, String)>) -> bool { + if subtree.len() < base_tree.len() { + return false; + } + let size_diff = subtree.len() - base_tree.len(); + for i in 0..base_tree.len() { + if subtree[size_diff + i] != base_tree[i] { + return false; + } + } + true +} + pub struct LdapHandler { dn: String, backend_handler: Backend, + pub base_dn: Vec<(String, String)>, + base_dn_str: String, } impl LdapHandler { - pub fn new(backend_handler: Backend) -> Self { + pub fn new(backend_handler: Backend, ldap_base_dn: String) -> Self { Self { dn: "Unauthenticated".to_string(), backend_handler, + base_dn: parse_distinguished_name(&ldap_base_dn).expect(&format!( + "Invalid value for ldap_base_dn in configuration: {}", + ldap_base_dn + )), + base_dn_str: ldap_base_dn, } } @@ -30,35 +116,35 @@ impl LdapHandler { } pub fn do_search(&mut self, lsr: &SearchRequest) -> Vec { - vec![ - lsr.gen_result_entry(LdapSearchResultEntry { - dn: "cn=hello,dc=example,dc=com".to_string(), - attributes: vec![ - LdapPartialAttribute { - atype: "objectClass".to_string(), - vals: vec!["cursed".to_string()], - }, - LdapPartialAttribute { - atype: "cn".to_string(), - vals: vec!["hello".to_string()], - }, - ], - }), - lsr.gen_result_entry(LdapSearchResultEntry { - dn: "cn=world,dc=example,dc=com".to_string(), - attributes: vec![ - LdapPartialAttribute { - atype: "objectClass".to_string(), - vals: vec!["cursed".to_string()], - }, - LdapPartialAttribute { - atype: "cn".to_string(), - vals: vec!["world".to_string()], - }, - ], - }), - lsr.gen_success(), - ] + let dn_parts = match parse_distinguished_name(&lsr.base) { + Ok(dn) => dn, + Err(_) => { + return vec![lsr.gen_error( + LdapResultCode::OperationsError, + format!(r#"Could not parse base DN: "{}""#, lsr.base), + )] + } + }; + if !is_subtree(&dn_parts, &self.base_dn) { + return vec![lsr.gen_success()]; + } + let users = match self.backend_handler.list_users(ListUsersRequest { + attrs: lsr.attrs.clone(), + }) { + Ok(users) => users, + Err(e) => { + return vec![lsr.gen_error( + LdapResultCode::Other, + format!(r#"Error during search for "{}": {}"#, lsr.base, e), + )] + } + }; + let mut res = users + .into_iter() + .map(|u| lsr.gen_result_entry(make_ldap_search_result_entry(u, &self.base_dn_str))) + .collect::>(); + res.push(lsr.gen_success()); + res } pub fn do_whoami(&mut self, wr: &WhoamiRequest) -> LdapMsg { @@ -87,6 +173,7 @@ impl LdapHandler { mod tests { use super::*; use crate::domain::handler::MockTestBackendHandler; + use chrono::NaiveDateTime; use mockall::predicate::eq; #[test] @@ -99,7 +186,7 @@ mod tests { })) .times(1) .return_once(|_| Ok(())); - let mut ldap_handler = LdapHandler::new(mock); + let mut ldap_handler = LdapHandler::new(mock, "dc=example,dc=com".to_string()); let request = WhoamiRequest { msgid: 1 }; assert_eq!( @@ -120,4 +207,139 @@ mod tests { request.gen_success("dn: test") ); } + + #[test] + fn test_is_subtree() { + let subtree1 = vec![ + ("ou".to_string(), "people".to_string()), + ("dc".to_string(), "example".to_string()), + ("dc".to_string(), "com".to_string()), + ]; + let root = vec![ + ("dc".to_string(), "example".to_string()), + ("dc".to_string(), "com".to_string()), + ]; + assert!(is_subtree(&subtree1, &root)); + assert!(!is_subtree(&vec![], &root)); + } + + #[test] + fn test_parse_distinguished_name() { + let parsed_dn = vec![ + ("ou".to_string(), "people".to_string()), + ("dc".to_string(), "example".to_string()), + ("dc".to_string(), "com".to_string()), + ]; + assert_eq!( + parse_distinguished_name("ou=people,dc=example,dc=com").expect("parsing failed"), + parsed_dn + ); + } + + #[test] + fn test_search() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_bind().return_once(|_| Ok(())); + mock.expect_list_users() + .with(eq(ListUsersRequest { attrs: vec![] })) + .times(1) + .return_once(|_| { + Ok(vec![ + User { + user_id: "bob_1".to_string(), + email: "bob@bobmail.bob".to_string(), + display_name: "Bôb Böbberson".to_string(), + first_name: "Bôb".to_string(), + last_name: "Böbberson".to_string(), + creation_date: NaiveDateTime::from_timestamp(1_000_000_000, 0), + }, + User { + user_id: "jim".to_string(), + email: "jim@cricket.jim".to_string(), + display_name: "Jimminy Cricket".to_string(), + first_name: "Jim".to_string(), + last_name: "Cricket".to_string(), + creation_date: NaiveDateTime::from_timestamp(1_003_000_000, 0), + }, + ]) + }); + let mut ldap_handler = LdapHandler::new(mock, "dc=example,dc=com".to_string()); + let request = SimpleBindRequest { + msgid: 1, + dn: "test".to_string(), + pw: "pass".to_string(), + }; + assert_eq!(ldap_handler.do_bind(&request), request.gen_success()); + let request = SearchRequest { + msgid: 2, + base: "ou=people,dc=example,dc=com".to_string(), + scope: LdapSearchScope::Base, + filter: LdapFilter::And(vec![]), + attrs: vec![], + }; + assert_eq!( + ldap_handler.do_search(&request), + vec![ + request.gen_result_entry(LdapSearchResultEntry { + dn: "cn=bob_1,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec!["inetOrgPerson".to_string(), "posixAccount".to_string(), "mailAccount".to_string()] + }, + LdapPartialAttribute { + atype: "uid".to_string(), + vals: vec!["bob_1".to_string()] + }, + LdapPartialAttribute { + atype: "mail".to_string(), + vals: vec!["bob@bobmail.bob".to_string()] + }, + LdapPartialAttribute { + atype: "givenName".to_string(), + vals: vec!["Bôb".to_string()] + }, + LdapPartialAttribute { + atype: "sn".to_string(), + vals: vec!["Böbberson".to_string()] + }, + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec!["Bôb Böbberson".to_string()] + } + ], + }), + request.gen_result_entry(LdapSearchResultEntry { + dn: "cn=jim,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec!["inetOrgPerson".to_string(), "posixAccount".to_string(), "mailAccount".to_string()] + }, + LdapPartialAttribute { + atype: "uid".to_string(), + vals: vec!["jim".to_string()] + }, + LdapPartialAttribute { + atype: "mail".to_string(), + vals: vec!["jim@cricket.jim".to_string()] + }, + LdapPartialAttribute { + atype: "givenName".to_string(), + vals: vec!["Jim".to_string()] + }, + LdapPartialAttribute { + atype: "sn".to_string(), + vals: vec!["Cricket".to_string()] + }, + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec!["Jimminy Cricket".to_string()] + } + ], + }), + request.gen_success() + ] + ); + } } diff --git a/src/infra/ldap_server.rs b/src/infra/ldap_server.rs index 41ad24b..6560e04 100644 --- a/src/infra/ldap_server.rs +++ b/src/infra/ldap_server.rs @@ -64,18 +64,21 @@ where { use futures_util::StreamExt; + let ldap_base_dn = config.ldap_base_dn.clone(); Ok( server_builder.bind("ldap", ("0.0.0.0", config.ldap_port), move || { let backend_handler = backend_handler.clone(); + let ldap_base_dn = ldap_base_dn.clone(); pipeline_factory(fn_service(move |mut stream: TcpStream| { let backend_handler = backend_handler.clone(); + let ldap_base_dn = ldap_base_dn.clone(); async move { // Configure the codec etc. let (r, w) = stream.split(); let mut requests = FramedRead::new(r, LdapCodec); let mut resp = FramedWrite::new(w, LdapCodec); - let mut session = LdapHandler::new(backend_handler); + let mut session = LdapHandler::new(backend_handler, ldap_base_dn); while let Some(msg) = requests.next().await { if !handle_incoming_message(msg, &mut resp, &mut session).await? {