diff --git a/Cargo.lock b/Cargo.lock index 56eb8f9..733a60a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2185,13 +2185,16 @@ dependencies = [ [[package]] name = "ldap3_proto" -version = "0.2.3" -source = "git+https://github.com/nitnelave/ldap3_server/?rev=7b50b2b82c383f5f70e02e11072bb916629ed2bc#7b50b2b82c383f5f70e02e11072bb916629ed2bc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4162706b6f3b3d58f577990e22e9a0e03e2f9bedc2b8181d8abab2498da32003" dependencies = [ "bytes", "lber", + "peg", "tokio-util 0.7.3", "tracing", + "uuid 1.2.2", ] [[package]] @@ -2864,6 +2867,33 @@ dependencies = [ "syn", ] +[[package]] +name = "peg" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07f2cafdc3babeebc087e499118343442b742cc7c31b4d054682cc598508554" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a90084dc05cf0428428e3d12399f39faad19b0909f64fb9170c9fdd6d9cd49b" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa00462b37ead6d11a82c9d568b26682d78e0477dc02d1966c013af80969739" + [[package]] name = "pem-rfc7468" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index f2283cd..85e1dd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,11 +8,6 @@ members = [ default-members = ["server"] -# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged. -[patch.crates-io.ldap3_proto] -git = 'https://github.com/nitnelave/ldap3_server/' -rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc' - [patch.crates-io.opaque-ke] git = 'https://github.com/nitnelave/opaque-ke/' branch = 'zeroize_1.5' diff --git a/app/src/components/app.rs b/app/src/components/app.rs index 7f35c43..a41346f 100644 --- a/app/src/components/app.rs +++ b/app/src/components/app.rs @@ -13,10 +13,13 @@ use crate::{ user_details::UserDetails, user_table::UserTable, }, - infra::cookies::get_cookie, + infra::{api::HostService, cookies::get_cookie}, +}; + +use yew::{ + prelude::*, + services::{fetch::FetchTask, ConsoleService}, }; -use yew::prelude::*; -use yew::services::ConsoleService; use yew_router::{ agent::{RouteAgentDispatcher, RouteRequest}, route::Route, @@ -29,11 +32,14 @@ pub struct App { user_info: Option<(String, bool)>, redirect_to: Option, route_dispatcher: RouteAgentDispatcher, + password_reset_enabled: bool, + task: Option, } pub enum Msg { Login((String, bool)), Logout, + PasswordResetProbeFinished(anyhow::Result), } impl Component for App { @@ -58,7 +64,15 @@ impl Component for App { }), redirect_to: Self::get_redirect_route(), route_dispatcher: RouteAgentDispatcher::new(), + password_reset_enabled: false, + task: None, }; + app.task = Some( + HostService::probe_password_reset( + app.link.callback_once(Msg::PasswordResetProbeFinished), + ) + .unwrap(), + ); app.apply_initial_redirections(); app } @@ -82,6 +96,16 @@ impl Component for App { self.user_info = None; self.redirect_to = None; } + Msg::PasswordResetProbeFinished(Ok(enabled)) => { + self.task = None; + self.password_reset_enabled = enabled; + } + Msg::PasswordResetProbeFinished(Err(err)) => { + self.task = None; + ConsoleService::error(&format!( + "Could not probe for password reset support: {err:#}" + )); + } } if self.user_info.is_none() { self.route_dispatcher @@ -97,6 +121,7 @@ impl Component for App { fn view(&self) -> Html { let link = self.link.clone(); let is_admin = self.is_admin(); + let password_reset_enabled = self.password_reset_enabled; html! {
{self.view_banner()} @@ -104,7 +129,7 @@ impl Component for App {
- render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin)) + render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin, password_reset_enabled)) />
@@ -135,6 +160,10 @@ impl App { let route_service = RouteService::<()>::new(); let current_route = route_service.get_path(); if current_route.contains("reset-password") { + if !self.password_reset_enabled { + self.route_dispatcher + .send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login))); + } return; } match &self.user_info { @@ -162,10 +191,15 @@ impl App { } } - fn dispatch_route(switch: AppRoute, link: &ComponentLink, is_admin: bool) -> Html { + fn dispatch_route( + switch: AppRoute, + link: &ComponentLink, + is_admin: bool, + password_reset_enabled: bool, + ) -> Html { match switch { AppRoute::Login => html! { - + }, AppRoute::CreateUser => html! { @@ -200,11 +234,23 @@ impl App { AppRoute::ChangePassword(username) => html! { }, - AppRoute::StartResetPassword => html! { - - }, + AppRoute::StartResetPassword => { + if password_reset_enabled { + html! { + + } + } else { + App::dispatch_route(AppRoute::Login, link, is_admin, password_reset_enabled) + } + } AppRoute::FinishResetPassword(token) => html! { - + if password_reset_enabled { + html! { + + } + } else { + App::dispatch_route(AppRoute::Login, link, is_admin, password_reset_enabled) + } }, } } diff --git a/app/src/components/login.rs b/app/src/components/login.rs index 0503e95..06adc6e 100644 --- a/app/src/components/login.rs +++ b/app/src/components/login.rs @@ -30,6 +30,7 @@ pub struct FormModel { #[derive(Clone, PartialEq, Properties)] pub struct Props { pub on_logged_in: Callback<(String, bool)>, + pub password_reset_enabled: bool, } pub enum Msg { @@ -147,6 +148,7 @@ impl Component for LoginForm { fn view(&self) -> Html { type Field = yew_form::Field; + let password_reset_enabled = self.common.password_reset_enabled; if self.refreshing { html! {
@@ -198,12 +200,18 @@ impl Component for LoginForm { {"Login"} - - {"Forgot your password?"} - + { if password_reset_enabled { + html! { + + {"Forgot your password?"} + + } + } else { + html!{} + }}
{ if let Some(e) = &self.common.error { diff --git a/app/src/infra/api.rs b/app/src/infra/api.rs index a210b9a..49e1b0e 100644 --- a/app/src/infra/api.rs +++ b/app/src/infra/api.rs @@ -3,9 +3,11 @@ use anyhow::{anyhow, Context, Result}; use graphql_client::GraphQLQuery; use lldap_auth::{login, registration, JWTClaims}; -use yew::callback::Callback; -use yew::format::Json; -use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response}; +use yew::{ + callback::Callback, + format::Json, + services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response}, +}; #[derive(Default)] pub struct HostService {} @@ -286,4 +288,17 @@ impl HostService { "Could not validate token", ) } + + pub fn probe_password_reset(callback: Callback>) -> Result { + let request = Request::get("/auth/reset/step1/lldap_unlikely_very_long_user_name") + .header("Content-Type", "application/json") + .body(yew::format::Nothing)?; + FetchService::fetch_with_options( + request, + get_default_options(), + create_handler(callback, move |status: http::StatusCode, _data: String| { + Ok(status != http::StatusCode::NOT_FOUND) + }), + ) + } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 04d212f..824b569 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,7 +28,7 @@ itertools = "0.10.1" juniper = "0.15.10" juniper_actix = "0.4.0" jwt = "0.13" -ldap3_proto = "*" +ldap3_proto = ">=0.3.1" log = "*" orion = "0.16" rustls = "0.20" diff --git a/server/src/domain/handler.rs b/server/src/domain/handler.rs index d93657d..1b2cb4f 100644 --- a/server/src/domain/handler.rs +++ b/server/src/domain/handler.rs @@ -14,13 +14,46 @@ pub struct BindRequest { pub password: String, } +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] +pub struct SubStringFilter { + pub initial: Option, + pub any: Vec, + pub final_: Option, +} + +impl SubStringFilter { + pub fn to_sql_filter(&self) -> String { + let mut filter = String::with_capacity( + self.initial.as_ref().map(String::len).unwrap_or_default() + + 1 + + self.any.iter().map(String::len).sum::() + + self.any.len() + + self.final_.as_ref().map(String::len).unwrap_or_default(), + ); + if let Some(f) = &self.initial { + filter.push_str(&f.to_ascii_lowercase()); + } + filter.push('%'); + for part in self.any.iter() { + filter.push_str(&part.to_ascii_lowercase()); + filter.push('%'); + } + if let Some(f) = &self.final_ { + filter.push_str(&f.to_ascii_lowercase()); + } + filter + } +} + #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] pub enum UserRequestFilter { And(Vec), Or(Vec), Not(Box), UserId(UserId), + UserIdSubString(SubStringFilter), Equality(UserColumn, String), + SubString(UserColumn, SubStringFilter), // Check if a user belongs to a group identified by name. MemberOf(String), // Same, by id. @@ -43,6 +76,7 @@ pub enum GroupRequestFilter { Or(Vec), Not(Box), DisplayName(String), + DisplayNameSubString(SubStringFilter), Uuid(Uuid), GroupId(GroupId), // Check if the group contains a user identified by uid. diff --git a/server/src/domain/ldap/group.rs b/server/src/domain/ldap/group.rs index 4555e67..00bde72 100644 --- a/server/src/domain/ldap/group.rs +++ b/server/src/domain/ldap/group.rs @@ -17,7 +17,7 @@ use super::{ }, }; -fn get_group_attribute( +pub fn get_group_attribute( group: &Group, base_dn_str: &str, attribute: &str, @@ -29,8 +29,8 @@ fn get_group_attribute( "objectclass" => vec![b"groupOfUniqueNames".to_vec()], // Always returned as part of the base response. "dn" | "distinguishedname" => return None, - "cn" | "uid" => vec![group.display_name.clone().into_bytes()], - "entryuuid" => vec![group.uuid.to_string().into_bytes()], + "cn" | "uid" | "id" => vec![group.display_name.clone().into_bytes()], + "entryuuid" | "uuid" => vec![group.uuid.to_string().into_bytes()], "member" | "uniquemember" => group .users .iter() @@ -73,6 +73,10 @@ const ALL_GROUP_ATTRIBUTE_KEYS: &[&str] = &[ "entryuuid", ]; +fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> { + expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS) +} + fn make_ldap_search_group_result_entry( group: Group, base_dn_str: &str, @@ -80,7 +84,7 @@ fn make_ldap_search_group_result_entry( user_filter: &Option<&UserId>, ignored_group_attributes: &[String], ) -> LdapSearchResultEntry { - let expanded_attributes = expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS); + let expanded_attributes = expand_group_attribute_wildcards(attributes); LdapSearchResultEntry { dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str), @@ -174,6 +178,21 @@ fn convert_group_filter( || map_group_field(field).is_some(), )) } + LdapFilter::Substring(field, substring_filter) => { + let field = &field.to_ascii_lowercase(); + match map_group_field(field.as_str()) { + Some(GroupColumn::DisplayName) => Ok(GroupRequestFilter::DisplayNameSubString( + substring_filter.clone().into(), + )), + _ => Err(LdapError { + code: LdapResultCode::UnwillingToPerform, + message: format!( + "Unsupported group attribute for substring filter: {:?}", + field + ), + }), + } + } _ => Err(LdapError { code: LdapResultCode::UnwillingToPerform, message: format!("Unsupported group filter: {:?}", filter), @@ -185,11 +204,10 @@ fn convert_group_filter( pub async fn get_groups_list( ldap_info: &LdapInfo, ldap_filter: &LdapFilter, - attributes: &[String], base: &str, user_filter: &Option<&UserId>, backend: &mut Backend, -) -> LdapResult> { +) -> LdapResult> { debug!(?ldap_filter); let filter = convert_group_filter(ldap_info, ldap_filter)?; let parsed_filters = match user_filter { @@ -200,24 +218,28 @@ pub async fn get_groups_list( } }; debug!(?parsed_filters); - let groups = backend + backend .list_groups(Some(parsed_filters)) .await .map_err(|e| LdapError { code: LdapResultCode::Other, message: format!(r#"Error while listing groups "{}": {:#}"#, base, e), - })?; - - Ok(groups - .into_iter() - .map(|u| { - LdapOp::SearchResultEntry(make_ldap_search_group_result_entry( - u, - &ldap_info.base_dn_str, - attributes, - user_filter, - &ldap_info.ignored_group_attributes, - )) }) - .collect::>()) +} + +pub fn convert_groups_to_ldap_op<'a>( + groups: Vec, + attributes: &'a [String], + ldap_info: &'a LdapInfo, + user_filter: &'a Option<&'a UserId>, +) -> impl Iterator + 'a { + groups.into_iter().map(move |g| { + LdapOp::SearchResultEntry(make_ldap_search_group_result_entry( + g, + &ldap_info.base_dn_str, + attributes, + user_filter, + &ldap_info.ignored_group_attributes, + )) + }) } diff --git a/server/src/domain/ldap/user.rs b/server/src/domain/ldap/user.rs index caddb6d..603bb05 100644 --- a/server/src/domain/ldap/user.rs +++ b/server/src/domain/ldap/user.rs @@ -10,7 +10,7 @@ use crate::domain::{ error::LdapError, utils::{expand_attribute_wildcards, get_user_id_from_distinguished_name}, }, - types::{GroupDetails, User, UserColumn, UserId}, + types::{GroupDetails, User, UserAndGroups, UserColumn, UserId}, }; use super::{ @@ -18,7 +18,7 @@ use super::{ utils::{get_group_id_from_distinguished_name, map_user_field, LdapInfo}, }; -fn get_user_attribute( +pub fn get_user_attribute( user: &User, attribute: &str, base_dn_str: &str, @@ -35,12 +35,12 @@ fn get_user_attribute( ], // dn is always returned as part of the base response. "dn" | "distinguishedname" => return None, - "uid" => vec![user.user_id.to_string().into_bytes()], - "entryuuid" => vec![user.uuid.to_string().into_bytes()], - "mail" => vec![user.email.clone().into_bytes()], - "givenname" => vec![user.first_name.clone()?.into_bytes()], - "sn" => vec![user.last_name.clone()?.into_bytes()], - "jpegphoto" => vec![user.avatar.clone()?.into_bytes()], + "uid" | "user_id" | "id" => vec![user.user_id.to_string().into_bytes()], + "entryuuid" | "uuid" => vec![user.uuid.to_string().into_bytes()], + "mail" | "email" => vec![user.email.clone().into_bytes()], + "givenname" | "first_name" | "firstname" => vec![user.first_name.clone()?.into_bytes()], + "sn" | "last_name" | "lastname" => vec![user.last_name.clone()?.into_bytes()], + "jpegphoto" | "avatar" => vec![user.avatar.clone()?.into_bytes()], "memberof" => groups .into_iter() .flatten() @@ -53,10 +53,12 @@ fn get_user_attribute( }) .collect(), "cn" | "displayname" => vec![user.display_name.clone()?.into_bytes()], - "createtimestamp" | "modifytimestamp" => vec![chrono::Utc - .from_utc_datetime(&user.creation_date) - .to_rfc3339() - .into_bytes()], + "creationdate" | "creation_date" | "createtimestamp" | "modifytimestamp" => { + vec![chrono::Utc + .from_utc_datetime(&user.creation_date) + .to_rfc3339() + .into_bytes()] + } "1.1" => return None, // We ignore the operational attribute wildcard. "+" => return None, @@ -99,15 +101,15 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[ fn make_ldap_search_user_result_entry( user: User, base_dn_str: &str, - attributes: &[&str], + attributes: &[String], groups: Option<&[GroupDetails]>, ignored_user_attributes: &[String], ) -> LdapSearchResultEntry { + let expanded_attributes = expand_user_attribute_wildcards(attributes); let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str); - LdapSearchResultEntry { dn, - attributes: attributes + attributes: expanded_attributes .iter() .filter_map(|a| { let values = @@ -181,6 +183,28 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult< || map_user_field(field).is_some(), )) } + LdapFilter::Substring(field, substring_filter) => { + let field = &field.to_ascii_lowercase(); + match map_user_field(field.as_str()) { + Some(UserColumn::UserId) => Ok(UserRequestFilter::UserIdSubString( + substring_filter.clone().into(), + )), + None + | Some(UserColumn::CreationDate) + | Some(UserColumn::Avatar) + | Some(UserColumn::Uuid) => Err(LdapError { + code: LdapResultCode::UnwillingToPerform, + message: format!( + "Unsupported user attribute for substring filter: {:?}", + field + ), + }), + Some(field) => Ok(UserRequestFilter::SubString( + field, + substring_filter.clone().into(), + )), + } + } _ => Err(LdapError { code: LdapResultCode::UnwillingToPerform, message: format!("Unsupported user filter: {:?}", filter), @@ -188,15 +212,19 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult< } } +fn expand_user_attribute_wildcards(attributes: &[String]) -> Vec<&str> { + expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS) +} + #[instrument(skip_all, level = "debug")] pub async fn get_user_list( ldap_info: &LdapInfo, ldap_filter: &LdapFilter, - attributes: &[String], + request_groups: bool, base: &str, user_filter: &Option<&UserId>, backend: &mut Backend, -) -> LdapResult> { +) -> LdapResult> { debug!(?ldap_filter); let filters = convert_user_filter(ldap_info, ldap_filter)?; let parsed_filters = match user_filter { @@ -207,28 +235,27 @@ pub async fn get_user_list( } }; debug!(?parsed_filters); - let expanded_attributes = expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS); - let need_groups = expanded_attributes - .iter() - .any(|s| s.to_ascii_lowercase() == "memberof"); - let users = backend - .list_users(Some(parsed_filters), need_groups) + backend + .list_users(Some(parsed_filters), request_groups) .await .map_err(|e| LdapError { code: LdapResultCode::Other, message: format!(r#"Error while searching user "{}": {:#}"#, base, e), - })?; - - Ok(users - .into_iter() - .map(|u| { - LdapOp::SearchResultEntry(make_ldap_search_user_result_entry( - u.user, - &ldap_info.base_dn_str, - &expanded_attributes, - u.groups.as_deref(), - &ldap_info.ignored_user_attributes, - )) }) - .collect::>()) +} + +pub fn convert_users_to_ldap_op<'a>( + users: Vec, + attributes: &'a [String], + ldap_info: &'a LdapInfo, +) -> impl Iterator + 'a { + users.into_iter().map(move |u| { + LdapOp::SearchResultEntry(make_ldap_search_user_result_entry( + u.user, + &ldap_info.base_dn_str, + attributes, + u.groups.as_deref(), + &ldap_info.ignored_user_attributes, + )) + }) } diff --git a/server/src/domain/ldap/utils.rs b/server/src/domain/ldap/utils.rs index e2cbec4..852d6e6 100644 --- a/server/src/domain/ldap/utils.rs +++ b/server/src/domain/ldap/utils.rs @@ -1,12 +1,29 @@ use itertools::Itertools; -use ldap3_proto::LdapResultCode; +use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode}; use tracing::{debug, instrument, warn}; use crate::domain::{ + handler::SubStringFilter, ldap::error::{LdapError, LdapResult}, types::{GroupColumn, UserColumn, UserId}, }; +impl From for SubStringFilter { + fn from( + LdapSubstringFilter { + initial, + any, + final_, + }: LdapSubstringFilter, + ) -> Self { + Self { + initial, + any, + final_, + } + } +} + fn make_dn_pair(mut iter: I) -> LdapResult<(String, String)> where I: Iterator, @@ -141,9 +158,9 @@ pub fn map_user_field(field: &str) -> Option { "uid" | "user_id" | "id" => UserColumn::UserId, "mail" | "email" => UserColumn::Email, "cn" | "displayname" | "display_name" => UserColumn::DisplayName, - "givenname" | "first_name" => UserColumn::FirstName, - "sn" | "last_name" => UserColumn::LastName, - "avatar" => UserColumn::Avatar, + "givenname" | "first_name" | "firstname" => UserColumn::FirstName, + "sn" | "last_name" | "lastname" => UserColumn::LastName, + "avatar" | "jpegphoto" => UserColumn::Avatar, "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => { UserColumn::CreationDate } diff --git a/server/src/domain/sql_group_backend_handler.rs b/server/src/domain/sql_group_backend_handler.rs index e5677a4..afffb5c 100644 --- a/server/src/domain/sql_group_backend_handler.rs +++ b/server/src/domain/sql_group_backend_handler.rs @@ -7,7 +7,7 @@ use crate::domain::{ }; use async_trait::async_trait; use sea_orm::{ - sea_query::{Cond, IntoCondition, SimpleExpr}, + sea_query::{Alias, Cond, Expr, Func, IntoCondition, SimpleExpr}, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait, }; @@ -15,6 +15,7 @@ use tracing::{debug, instrument}; fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond { use GroupRequestFilter::*; + let group_table = Alias::new("groups"); match filter { And(fs) => { if fs.is_empty() { @@ -46,6 +47,12 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond { .into_query(), ) .into_condition(), + DisplayNameSubString(filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col(( + group_table, + GroupColumn::DisplayName, + )))) + .like(filter.to_sql_filter()) + .into_condition(), } } @@ -146,7 +153,7 @@ impl GroupBackendHandler for SqlBackendHandler { #[cfg(test)] mod tests { use super::*; - use crate::domain::{sql_backend_handler::tests::*, types::UserId}; + use crate::domain::{handler::SubStringFilter, sql_backend_handler::tests::*, types::UserId}; async fn get_group_ids( handler: &SqlBackendHandler, @@ -221,6 +228,24 @@ mod tests { ); } + #[tokio::test] + async fn test_list_groups_substring_filter() { + let fixture = TestFixture::new().await; + assert_eq!( + get_group_ids( + &fixture.handler, + Some(GroupRequestFilter::DisplayNameSubString(SubStringFilter { + initial: Some("be".to_owned()), + any: vec!["sT".to_owned()], + final_: Some("P".to_owned()), + })), + ) + .await, + // Best group + vec![fixture.groups[0]] + ); + } + #[tokio::test] async fn test_get_group_details() { let fixture = TestFixture::new().await; diff --git a/server/src/domain/sql_user_backend_handler.rs b/server/src/domain/sql_user_backend_handler.rs index a481565..529c3e9 100644 --- a/server/src/domain/sql_user_backend_handler.rs +++ b/server/src/domain/sql_user_backend_handler.rs @@ -8,7 +8,7 @@ use super::{ use async_trait::async_trait; use sea_orm::{ entity::IntoActiveValue, - sea_query::{Alias, Cond, Expr, IntoColumnRef, IntoCondition, SimpleExpr}, + sea_query::{Alias, Cond, Expr, Func, IntoColumnRef, IntoCondition, SimpleExpr}, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait, Set, }; @@ -49,8 +49,15 @@ fn get_user_filter_expr(filter: UserRequestFilter) -> Cond { MemberOfId(group_id) => Expr::col((group_table, GroupColumn::GroupId)) .eq(group_id) .into_condition(), + UserIdSubString(filter) => UserColumn::UserId + .like(&filter.to_sql_filter()) + .into_condition(), + SubString(col, filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col(col))) + .like(filter.to_sql_filter()) + .into_condition(), } } + fn to_value(opt_name: &Option) -> ActiveValue> { match opt_name { None => ActiveValue::NotSet, @@ -236,6 +243,7 @@ impl UserBackendHandler for SqlBackendHandler { mod tests { use super::*; use crate::domain::{ + handler::SubStringFilter, sql_backend_handler::tests::*, types::{JpegPhoto, UserColumn}, }; @@ -286,6 +294,31 @@ mod tests { assert_eq!(users, vec!["bob"]); } + #[tokio::test] + async fn test_list_users_substring_filter() { + let fixture = TestFixture::new().await; + let users = get_user_names( + &fixture.handler, + Some(UserRequestFilter::And(vec![ + UserRequestFilter::UserIdSubString(SubStringFilter { + initial: Some("Pa".to_owned()), + any: vec!["rI".to_owned()], + final_: Some("K".to_owned()), + }), + UserRequestFilter::SubString( + UserColumn::FirstName, + SubStringFilter { + initial: None, + any: vec!["r".to_owned(), "t".to_owned()], + final_: None, + }, + ), + ])), + ) + .await; + assert_eq!(users, vec!["patrick"]); + } + #[tokio::test] async fn test_list_users_false_filter() { let fixture = TestFixture::new().await; diff --git a/server/src/infra/auth_service.rs b/server/src/infra/auth_service.rs index e8f8c7e..ce7bc1b 100644 --- a/server/src/infra/auth_service.rs +++ b/server/src/infra/auth_service.rs @@ -677,7 +677,7 @@ pub(crate) fn check_if_token_is_valid( }) } -pub fn configure_server(cfg: &mut web::ServiceConfig) +pub fn configure_server(cfg: &mut web::ServiceConfig, enable_password_reset: bool) where Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static, { @@ -694,14 +694,6 @@ where web::resource("/simple/login").route(web::post().to(simple_login_handler::)), ) .service(web::resource("/refresh").route(web::get().to(get_refresh_handler::))) - .service( - web::resource("/reset/step1/{user_id}") - .route(web::get().to(get_password_reset_step1_handler::)), - ) - .service( - web::resource("/reset/step2/{token}") - .route(web::get().to(get_password_reset_step2_handler::)), - ) .service(web::resource("/logout").route(web::get().to(get_logout_handler::))) .service( web::scope("/opaque/register") @@ -715,4 +707,14 @@ where .route(web::post().to(opaque_register_finish_handler::)), ), ); + if enable_password_reset { + cfg.service( + web::resource("/reset/step1/{user_id}") + .route(web::get().to(get_password_reset_step1_handler::)), + ) + .service( + web::resource("/reset/step2/{token}") + .route(web::get().to(get_password_reset_step2_handler::)), + ); + } } diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs index 2c5e6ce..e24bcf0 100644 --- a/server/src/infra/ldap_handler.rs +++ b/server/src/infra/ldap_handler.rs @@ -3,23 +3,23 @@ use crate::{ handler::{BackendHandler, BindRequest, CreateUserRequest, LoginHandler}, ldap::{ error::{LdapError, LdapResult}, - group::get_groups_list, - user::get_user_list, + group::{convert_groups_to_ldap_op, get_groups_list}, + user::{convert_users_to_ldap_op, get_user_list}, utils::{ get_user_id_from_distinguished_name, is_subtree, parse_distinguished_name, LdapInfo, }, }, opaque_handler::OpaqueHandler, - types::{JpegPhoto, UserId}, + types::{Group, JpegPhoto, UserAndGroups, UserId}, }, infra::auth_service::{Permission, ValidationResults}, }; use anyhow::Result; use ldap3_proto::proto::{ - LdapAddRequest, LdapBindCred, LdapBindRequest, LdapBindResponse, LdapExtendedRequest, - LdapExtendedResponse, LdapFilter, LdapOp, LdapPartialAttribute, LdapPasswordModifyRequest, - LdapResult as LdapResultOp, LdapResultCode, LdapSearchRequest, LdapSearchResultEntry, - LdapSearchScope, + LdapAddRequest, LdapBindCred, LdapBindRequest, LdapBindResponse, LdapCompareRequest, + LdapDerefAliases, LdapExtendedRequest, LdapExtendedResponse, LdapFilter, LdapOp, + LdapPartialAttribute, LdapPasswordModifyRequest, LdapResult as LdapResultOp, LdapResultCode, + LdapSearchRequest, LdapSearchResultEntry, LdapSearchScope, }; use std::collections::HashMap; use tracing::{debug, instrument, warn}; @@ -71,6 +71,23 @@ fn get_search_scope(base_dn: &[(String, String)], dn_parts: &[(String, String)]) } } +fn make_search_request>( + base: &str, + filter: LdapFilter, + attrs: Vec, +) -> LdapSearchRequest { + LdapSearchRequest { + base: base.to_string(), + scope: LdapSearchScope::Base, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter, + attrs: attrs.into_iter().map(Into::into).collect(), + } +} + fn make_search_success() -> LdapOp { make_search_error(LdapResultCode::Success, "".to_string()) } @@ -334,6 +351,18 @@ impl LdapHandler LdapResult> { + let user_info = self.user_info.as_ref().ok_or_else(|| LdapError { + code: LdapResultCode::InsufficentAccessRights, + message: "No user currently bound".to_string(), + })?; + Ok(if user_info.is_admin_or_readonly() { + None + } else { + Some(user_info.user.clone()) + }) + } + pub async fn do_search_or_dse( &mut self, request: &LdapSearchRequest, @@ -349,30 +378,19 @@ impl LdapHandler, - ) -> LdapResult> { - let user_filter = user_filter.as_ref(); + user_filter: &Option<&UserId>, + ) -> LdapResult<(Option>, Option>)> { let dn_parts = parse_distinguished_name(&request.base.to_ascii_lowercase())?; let scope = get_search_scope(&self.ldap_info.base_dn, &dn_parts); debug!(?request.base, ?scope); // Disambiguate the lifetimes. - fn cast(x: T) -> T + fn cast<'a, T, R, B: 'a>(x: T) -> T where T: Fn(&'a mut B, &'a LdapFilter) -> R + 'a, { @@ -380,12 +398,16 @@ impl LdapHandler LdapHandler = match scope { - SearchScope::Global => { - let mut results = Vec::new(); - results.extend(get_user_list(&mut self.backend_handler, &request.filter).await?); - results.extend(get_group_list(&mut self.backend_handler, &request.filter).await?); - results - } - SearchScope::Users => get_user_list(&mut self.backend_handler, &request.filter).await?, - SearchScope::Groups => { - get_group_list(&mut self.backend_handler, &request.filter).await? - } + Ok(match scope { + SearchScope::Global => ( + Some(get_user_list(&mut self.backend_handler, &request.filter).await?), + Some(get_group_list(&mut self.backend_handler, &request.filter).await?), + ), + SearchScope::Users => ( + Some(get_user_list(&mut self.backend_handler, &request.filter).await?), + None, + ), + SearchScope::Groups => ( + None, + Some(get_group_list(&mut self.backend_handler, &request.filter).await?), + ), SearchScope::User(filter) => { let filter = LdapFilter::And(vec![request.filter.clone(), filter]); - get_user_list(&mut self.backend_handler, &filter).await? + ( + Some(get_user_list(&mut self.backend_handler, &filter).await?), + None, + ) } SearchScope::Group(filter) => { let filter = LdapFilter::And(vec![request.filter.clone(), filter]); - get_group_list(&mut self.backend_handler, &filter).await? + ( + None, + Some(get_group_list(&mut self.backend_handler, &filter).await?), + ) } SearchScope::Unknown => { warn!( r#"The requested search tree "{}" matches neither the user subtree "ou=people,{}" nor the group subtree "ou=groups,{}""#, &request.base, &self.ldap_info.base_dn_str, &self.ldap_info.base_dn_str ); - Vec::new() + (None, None) } SearchScope::Invalid => { // Search path is not in our tree, just return an empty success. @@ -433,9 +462,33 @@ impl LdapHandler LdapResult> { + let user_filter = self.get_user_permission_filter()?; + let user_filter = user_filter.as_ref(); + let (users, groups) = self.do_search_internal(request, &user_filter).await?; + + let mut results = Vec::new(); + if let Some(users) = users { + results.extend(convert_users_to_ldap_op( + users, + &request.attrs, + &self.ldap_info, + )); + } + if let Some(groups) = groups { + results.extend(convert_groups_to_ldap_op( + groups, + &request.attrs, + &self.ldap_info, + &user_filter, + )); + } if results.is_empty() || matches!(results[results.len() - 1], LdapOp::SearchResultEntry(_)) { results.push(make_search_success()); @@ -527,6 +580,55 @@ impl LdapHandler LdapResult> { + let req = make_search_request::( + &self.ldap_info.base_dn_str, + LdapFilter::Equality("dn".to_string(), request.dn.to_string()), + vec![request.atype.clone()], + ); + let entries = self.do_search(&req).await?; + if entries.len() > 2 { + // SearchResultEntry + SearchResultDone + return Err(LdapError { + code: LdapResultCode::OperationsError, + message: "Too many search results".to_string(), + }); + } + + match entries.first() { + Some(LdapOp::SearchResultEntry(entry)) => { + let available = entry + .attributes + .iter() + .any(|attr| attr.atype == request.atype && attr.vals.contains(&request.val)); + Ok(vec![LdapOp::CompareResult(LdapResultOp { + code: if available { + LdapResultCode::CompareTrue + } else { + LdapResultCode::CompareFalse + }, + matcheddn: request.dn, + message: "".to_string(), + referral: vec![], + })]) + } + Some(LdapOp::SearchResultDone(_)) => Ok(vec![LdapOp::CompareResult(LdapResultOp { + code: LdapResultCode::NoSuchObject, + matcheddn: self.ldap_info.base_dn_str.clone(), + message: "".to_string(), + referral: vec![], + })]), + None => Err(LdapError { + code: LdapResultCode::OperationsError, + message: "Search request returned nothing".to_string(), + }), + _ => Err(LdapError { + code: LdapResultCode::OperationsError, + message: "Unexpected results from search".to_string(), + }), + } + } + pub async fn handle_ldap_message(&mut self, ldap_op: LdapOp) -> Option> { Some(match ldap_op { LdapOp::BindRequest(request) => { @@ -555,6 +657,10 @@ impl LdapHandler self + .do_compare(request) + .await + .unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]), op => vec![make_extended_response( LdapResultCode::UnwillingToPerform, format!("Unsupported operation: {:#?}", op), @@ -572,7 +678,7 @@ mod tests { }; use async_trait::async_trait; use chrono::TimeZone; - use ldap3_proto::proto::{LdapDerefAliases, LdapSearchScope}; + use ldap3_proto::proto::{LdapDerefAliases, LdapSearchScope, LdapSubstringFilter}; use mockall::predicate::eq; use std::collections::HashSet; use tokio; @@ -625,23 +731,6 @@ mod tests { } } - fn make_search_request>( - base: &str, - filter: LdapFilter, - attrs: Vec, - ) -> LdapSearchRequest { - LdapSearchRequest { - base: base.to_string(), - scope: LdapSearchScope::Base, - aliases: LdapDerefAliases::Never, - sizelimit: 0, - timelimit: 0, - typesonly: false, - filter, - attrs: attrs.into_iter().map(Into::into).collect(), - } - } - fn make_user_search_request>( filter: LdapFilter, attrs: Vec, @@ -649,6 +738,13 @@ mod tests { make_search_request::("ou=people,Dc=example,dc=com", filter, attrs) } + fn make_group_search_request>( + filter: LdapFilter, + attrs: Vec, + ) -> LdapSearchRequest { + make_search_request::("ou=groups,dc=example,dc=com", filter, attrs) + } + async fn setup_bound_handler_with_group( mut mock: MockTestBackendHandler, group: &str, @@ -778,7 +874,7 @@ mod tests { mock.expect_list_users() .with( eq(Some(UserRequestFilter::And(vec![ - UserRequestFilter::from(true), + true.into(), UserRequestFilter::UserId(UserId::new("test")), ]))), eq(false), @@ -813,7 +909,7 @@ mod tests { async fn test_search_readonly_user() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() - .with(eq(Some(UserRequestFilter::from(true))), eq(false)) + .with(eq(Some(true.into())), eq(false)) .times(1) .return_once(|_, _| Ok(vec![])); let mut ldap_handler = setup_bound_readonly_handler(mock).await; @@ -830,7 +926,7 @@ mod tests { async fn test_search_member_of() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() - .with(eq(Some(UserRequestFilter::from(true))), eq(true)) + .with(eq(Some(true.into())), eq(true)) .times(1) .return_once(|_, _| { Ok(vec![UserAndGroups { @@ -873,7 +969,7 @@ mod tests { mock.expect_list_users() .with( eq(Some(UserRequestFilter::And(vec![ - UserRequestFilter::from(true), + true.into(), UserRequestFilter::UserId(UserId::new("bob")), ]))), eq(false), @@ -1131,7 +1227,7 @@ mod tests { async fn test_search_groups() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_groups() - .with(eq(Some(GroupRequestFilter::from(true)))) + .with(eq(Some(true.into()))) .times(1) .return_once(|_| { Ok(vec![ @@ -1152,8 +1248,7 @@ mod tests { ]) }); let mut ldap_handler = setup_bound_admin_handler(mock).await; - let request = make_search_request( - "ou=groups,dc=example,dc=cOm", + let request = make_group_search_request( LdapFilter::And(vec![]), vec!["objectClass", "dn", "cn", "uniqueMember", "entryUuid"], ); @@ -1218,12 +1313,18 @@ mod tests { GroupRequestFilter::DisplayName("group_1".to_string()), GroupRequestFilter::Member(UserId::new("bob")), GroupRequestFilter::DisplayName("rockstars".to_string()), - GroupRequestFilter::from(true), - GroupRequestFilter::from(true), - GroupRequestFilter::from(true), - GroupRequestFilter::from(true), - GroupRequestFilter::Not(Box::new(GroupRequestFilter::from(false))), - GroupRequestFilter::from(false), + false.into(), + true.into(), + true.into(), + true.into(), + true.into(), + GroupRequestFilter::Not(Box::new(false.into())), + false.into(), + GroupRequestFilter::DisplayNameSubString(SubStringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }), ])))) .times(1) .return_once(|_| { @@ -1236,8 +1337,7 @@ mod tests { }]) }); let mut ldap_handler = setup_bound_admin_handler(mock).await; - let request = make_search_request( - "ou=groups,dc=example,dc=com", + let request = make_group_search_request( LdapFilter::And(vec![ LdapFilter::Equality("cN".to_string(), "Group_1".to_string()), LdapFilter::Equality( @@ -1248,6 +1348,10 @@ mod tests { "dn".to_string(), "uid=rockstars,ou=groups,dc=example,dc=com".to_string(), ), + LdapFilter::Equality( + "dn".to_string(), + "uid=rockstars,ou=people,dc=example,dc=com".to_string(), + ), LdapFilter::Equality("obJEctclass".to_string(), "groupofUniqueNames".to_string()), LdapFilter::Equality("objectclass".to_string(), "groupOfNames".to_string()), LdapFilter::Present("objectclass".to_string()), @@ -1256,6 +1360,14 @@ mod tests { "random_attribUte".to_string(), ))), LdapFilter::Equality("unknown_attribute".to_string(), "randomValue".to_string()), + LdapFilter::Substring( + "cn".to_owned(), + LdapSubstringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), ]), vec!["1.1"], ); @@ -1291,8 +1403,7 @@ mod tests { }]) }); let mut ldap_handler = setup_bound_admin_handler(mock).await; - let request = make_search_request( - "ou=groups,dc=example,dc=com", + let request = make_group_search_request( LdapFilter::Or(vec![LdapFilter::Not(Box::new(LdapFilter::Equality( "displayname".to_string(), "group_2".to_string(), @@ -1319,7 +1430,7 @@ mod tests { let mut mock = MockTestBackendHandler::new(); mock.expect_list_groups() .with(eq(Some(GroupRequestFilter::And(vec![ - GroupRequestFilter::from(true), + true.into(), GroupRequestFilter::DisplayName("rockstars".to_string()), ])))) .times(1) @@ -1342,6 +1453,22 @@ mod tests { ); } + #[tokio::test] + async fn test_search_groups_unsupported_substring() { + let mut ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + let request = make_group_search_request( + LdapFilter::Substring("member".to_owned(), LdapSubstringFilter::default()), + vec!["cn"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Err(LdapError { + code: LdapResultCode::UnwillingToPerform, + message: r#"Unsupported group attribute for substring filter: "member""#.to_owned() + }) + ); + } + #[tokio::test] async fn test_search_groups_error() { let mut mock = MockTestBackendHandler::new(); @@ -1358,8 +1485,7 @@ mod tests { )) }); let mut ldap_handler = setup_bound_admin_handler(mock).await; - let request = make_search_request( - "ou=groups,dc=example,dc=com", + let request = make_group_search_request( LdapFilter::Or(vec![LdapFilter::Not(Box::new(LdapFilter::Equality( "displayname".to_string(), "group_2".to_string(), @@ -1378,20 +1504,18 @@ mod tests { #[tokio::test] async fn test_search_groups_filter_error() { let mut ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; - let request = make_search_request( - "ou=groups,dc=example,dc=com", - LdapFilter::And(vec![LdapFilter::Substring( - "whatever".to_string(), - ldap3_proto::proto::LdapSubstringFilter::default(), + let request = make_group_search_request( + LdapFilter::And(vec![LdapFilter::Approx( + "whatever".to_owned(), + "value".to_owned(), )]), vec!["cn"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, - Err(LdapError{ + Err(LdapError { code: LdapResultCode::UnwillingToPerform, - message: r#"Unsupported group filter: Substring("whatever", LdapSubstringFilter { initial: None, any: [], final_: None })"# - .to_string() + message: r#"Unsupported group filter: Approx("whatever", "value")"#.to_string() }) ); } @@ -1407,12 +1531,26 @@ mod tests { "bob", )))), UserRequestFilter::UserId("bob_1".to_string().into()), - UserRequestFilter::from(true), - UserRequestFilter::from(false), - UserRequestFilter::from(true), - UserRequestFilter::from(true), - UserRequestFilter::from(false), - UserRequestFilter::from(false), + false.into(), + true.into(), + false.into(), + true.into(), + true.into(), + false.into(), + false.into(), + UserRequestFilter::UserIdSubString(SubStringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }), + UserRequestFilter::SubString( + UserColumn::FirstName, + SubStringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), ], )]))), eq(false), @@ -1430,12 +1568,32 @@ mod tests { "dn".to_string(), "uid=bob_1,ou=people,dc=example,dc=com".to_string(), ), + LdapFilter::Equality( + "dn".to_string(), + "uid=bob_1,ou=groups,dc=example,dc=com".to_string(), + ), LdapFilter::Equality("objectclass".to_string(), "persOn".to_string()), LdapFilter::Equality("objectclass".to_string(), "other".to_string()), LdapFilter::Present("objectClass".to_string()), LdapFilter::Present("uid".to_string()), LdapFilter::Present("unknown".to_string()), LdapFilter::Equality("unknown_attribute".to_string(), "randomValue".to_string()), + LdapFilter::Substring( + "uid".to_owned(), + LdapSubstringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), + LdapFilter::Substring( + "firstName".to_owned(), + LdapSubstringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), ])]), vec!["objectClass"], ); @@ -1560,7 +1718,7 @@ mod tests { }]) }); mock.expect_list_groups() - .with(eq(Some(GroupRequestFilter::from(true)))) + .with(eq(Some(true.into()))) .times(1) .return_once(|_| { Ok(vec![Group { @@ -1635,7 +1793,7 @@ mod tests { }]) }); mock.expect_list_groups() - .with(eq(Some(GroupRequestFilter::from(true)))) + .with(eq(Some(true.into()))) .returning(|_| { Ok(vec![Group { id: GroupId(1), @@ -1807,17 +1965,14 @@ mod tests { async fn test_search_unsupported_filters() { let mut ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = make_user_search_request( - LdapFilter::Substring( - "uid".to_string(), - ldap3_proto::proto::LdapSubstringFilter::default(), - ), + LdapFilter::Approx("uid".to_owned(), "value".to_owned()), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, - Err(LdapError{ + Err(LdapError { code: LdapResultCode::UnwillingToPerform, - message: r#"Unsupported user filter: Substring("uid", LdapSubstringFilter { initial: None, any: [], final_: None })"#.to_string() + message: r#"Unsupported user filter: Approx("uid", "value")"#.to_string() }) ); } @@ -2091,7 +2246,7 @@ mod tests { async fn test_search_filter_non_attribute() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() - .with(eq(Some(UserRequestFilter::from(true))), eq(false)) + .with(eq(Some(true.into())), eq(false)) .times(1) .return_once(|_, _| Ok(vec![])); let mut ldap_handler = setup_bound_admin_handler(mock).await; @@ -2104,4 +2259,178 @@ mod tests { Ok(vec![make_search_success()]) ); } + + #[tokio::test] + async fn test_compare_user() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users().returning(|f, g| { + assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob")))); + assert!(!g); + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("bob"), + email: "bob@bobmail.bob".to_string(), + ..Default::default() + }, + groups: None, + }]) + }); + mock.expect_list_groups().returning(|_| Ok(vec![])); + let mut ldap_handler = setup_bound_admin_handler(mock).await; + let dn = "uid=bob,ou=people,dc=example,dc=com"; + let request = LdapCompareRequest { + dn: dn.to_string(), + atype: "uid".to_owned(), + val: b"bob".to_vec(), + }; + assert_eq!( + ldap_handler.do_compare(request).await, + Ok(vec![LdapOp::CompareResult(LdapResultOp { + code: LdapResultCode::CompareTrue, + matcheddn: dn.to_string(), + message: "".to_string(), + referral: vec![], + })]) + ); + // Non-canonical attribute. + let request = LdapCompareRequest { + dn: dn.to_string(), + atype: "eMail".to_owned(), + val: b"bob@bobmail.bob".to_vec(), + }; + assert_eq!( + ldap_handler.do_compare(request).await, + Ok(vec![LdapOp::CompareResult(LdapResultOp { + code: LdapResultCode::CompareTrue, + matcheddn: dn.to_string(), + message: "".to_string(), + referral: vec![], + })]) + ); + } + + #[tokio::test] + async fn test_compare_group() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users().returning(|_, _| Ok(vec![])); + mock.expect_list_groups().returning(|f| { + assert_eq!(f, Some(GroupRequestFilter::DisplayName("group".to_owned()))); + Ok(vec![Group { + id: GroupId(1), + display_name: "group".to_string(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![UserId::new("bob")], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + }]) + }); + let mut ldap_handler = setup_bound_admin_handler(mock).await; + let dn = "uid=group,ou=groups,dc=example,dc=com"; + let request = LdapCompareRequest { + dn: dn.to_string(), + atype: "uid".to_owned(), + val: b"group".to_vec(), + }; + assert_eq!( + ldap_handler.do_compare(request).await, + Ok(vec![LdapOp::CompareResult(LdapResultOp { + code: LdapResultCode::CompareTrue, + matcheddn: dn.to_string(), + message: "".to_string(), + referral: vec![], + })]) + ); + } + + #[tokio::test] + async fn test_compare_not_found() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users().returning(|f, g| { + assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob")))); + assert!(!g); + Ok(vec![]) + }); + mock.expect_list_groups().returning(|_| Ok(vec![])); + let mut ldap_handler = setup_bound_admin_handler(mock).await; + let dn = "uid=bob,ou=people,dc=example,dc=com"; + let request = LdapCompareRequest { + dn: dn.to_string(), + atype: "uid".to_owned(), + val: b"bob".to_vec(), + }; + assert_eq!( + ldap_handler.do_compare(request).await, + Ok(vec![LdapOp::CompareResult(LdapResultOp { + code: LdapResultCode::NoSuchObject, + matcheddn: "dc=example,dc=com".to_owned(), + message: "".to_string(), + referral: vec![], + })]) + ); + } + + #[tokio::test] + async fn test_compare_no_match() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users().returning(|f, g| { + assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob")))); + assert!(!g); + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("bob"), + email: "bob@bobmail.bob".to_string(), + ..Default::default() + }, + groups: None, + }]) + }); + mock.expect_list_groups().returning(|_| Ok(vec![])); + let mut ldap_handler = setup_bound_admin_handler(mock).await; + let dn = "uid=bob,ou=people,dc=example,dc=com"; + let request = LdapCompareRequest { + dn: dn.to_string(), + atype: "mail".to_owned(), + val: b"bob@bob".to_vec(), + }; + assert_eq!( + ldap_handler.do_compare(request).await, + Ok(vec![LdapOp::CompareResult(LdapResultOp { + code: LdapResultCode::CompareFalse, + matcheddn: dn.to_string(), + message: "".to_string(), + referral: vec![], + })]) + ); + } + + #[tokio::test] + async fn test_compare_group_member() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users().returning(|_, _| Ok(vec![])); + mock.expect_list_groups().returning(|f| { + assert_eq!(f, Some(GroupRequestFilter::DisplayName("group".to_owned()))); + Ok(vec![Group { + id: GroupId(1), + display_name: "group".to_string(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![UserId::new("bob")], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + }]) + }); + let mut ldap_handler = setup_bound_admin_handler(mock).await; + let dn = "uid=group,ou=groups,dc=example,dc=com"; + let request = LdapCompareRequest { + dn: dn.to_string(), + atype: "uniqueMember".to_owned(), + val: b"uid=bob,ou=people,dc=example,dc=com".to_vec(), + }; + assert_eq!( + ldap_handler.do_compare(request).await, + Ok(vec![LdapOp::CompareResult(LdapResultOp { + code: LdapResultCode::CompareTrue, + matcheddn: dn.to_owned(), + message: "".to_string(), + referral: vec![], + })]) + ); + } } diff --git a/server/src/infra/tcp_server.rs b/server/src/infra/tcp_server.rs index 27a751e..c27846d 100644 --- a/server/src/infra/tcp_server.rs +++ b/server/src/infra/tcp_server.rs @@ -73,6 +73,7 @@ fn http_config( ) where Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static, { + let enable_password_reset = mail_options.enable_password_reset; cfg.app_data(web::Data::new(AppState:: { backend_handler, jwt_key: Hmac::new_varkey(jwt_secret.unsecure().as_bytes()).unwrap(), @@ -81,7 +82,10 @@ fn http_config( mail_options, })) .route("/health", web::get().to(|| HttpResponse::Ok().finish())) - .service(web::scope("/auth").configure(auth_service::configure_server::)) + .service( + web::scope("/auth") + .configure(|cfg| auth_service::configure_server::(cfg, enable_password_reset)), + ) // API endpoint. .service( web::scope("/api")