From 84fb9b0fd2c473f1a82a88760643100dc13abe7f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Sep 2025 00:42:51 +0200 Subject: [PATCH] Fix pwdChangedTime format to use LDAP GeneralizedTime instead of RFC3339 (#1300) When querying for pwdChangedTime, the timestamp is returned in RFC3339 format instead of the expected LDAP GeneralizedTime format (YYYYMMDDHHMMSSZ). This causes issues when LLDAP is used with systems like Keycloak that expect proper LDAP timestamp formatting. --- crates/ldap/src/core/user.rs | 31 ++++++---------- crates/ldap/src/core/utils.rs | 22 +++++++----- crates/ldap/src/search.rs | 67 ++++++++++++++++++++++++++++++----- 3 files changed, 82 insertions(+), 38 deletions(-) diff --git a/crates/ldap/src/core/user.rs b/crates/ldap/src/core/user.rs index 260efa3..7df2e65 100644 --- a/crates/ldap/src/core/user.rs +++ b/crates/ldap/src/core/user.rs @@ -3,10 +3,10 @@ use crate::core::{ utils::{ ExpandedAttributes, LdapInfo, UserFieldType, expand_attribute_wildcards, get_custom_attribute, get_group_id_from_distinguished_name_or_plain_name, - get_user_id_from_distinguished_name_or_plain_name, map_user_field, + get_user_id_from_distinguished_name_or_plain_name, map_user_field, to_generalized_time, }, }; -use chrono::TimeZone; + use ldap3_proto::{ LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, proto::LdapOp, }; @@ -87,24 +87,15 @@ pub fn get_user_attribute( UserFieldType::PrimaryField(UserColumn::DisplayName) => { vec![user.display_name.clone()?.into_bytes()] } - UserFieldType::PrimaryField(UserColumn::CreationDate) => vec![ - chrono::Utc - .from_utc_datetime(&user.creation_date) - .to_rfc3339() - .into_bytes(), - ], - UserFieldType::PrimaryField(UserColumn::ModifiedDate) => vec![ - chrono::Utc - .from_utc_datetime(&user.modified_date) - .to_rfc3339() - .into_bytes(), - ], - UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate) => vec![ - chrono::Utc - .from_utc_datetime(&user.password_modified_date) - .to_rfc3339() - .into_bytes(), - ], + UserFieldType::PrimaryField(UserColumn::CreationDate) => { + vec![to_generalized_time(&user.creation_date)] + } + UserFieldType::PrimaryField(UserColumn::ModifiedDate) => { + vec![to_generalized_time(&user.modified_date)] + } + UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate) => { + vec![to_generalized_time(&user.password_modified_date)] + } UserFieldType::Attribute(attr, _, _) => get_custom_attribute(&user.attributes, &attr)?, UserFieldType::NoMatch => match attribute.as_str() { "1.1" => return None, diff --git a/crates/ldap/src/core/utils.rs b/crates/ldap/src/core/utils.rs index 81a5d4e..182a680 100644 --- a/crates/ldap/src/core/utils.rs +++ b/crates/ldap/src/core/utils.rs @@ -3,7 +3,7 @@ use crate::core::{ group::{REQUIRED_GROUP_ATTRIBUTES, get_default_group_object_classes}, user::{REQUIRED_USER_ATTRIBUTES, get_default_user_object_classes}, }; -use chrono::TimeZone; +use chrono::{NaiveDateTime, TimeZone}; use itertools::join; use ldap3_proto::LdapResultCode; use lldap_domain::{ @@ -18,6 +18,16 @@ use lldap_domain_model::model::UserColumn; use std::collections::BTreeMap; use tracing::{debug, instrument, warn}; +/// Convert a NaiveDateTime to LDAP GeneralizedTime format (YYYYMMDDHHMMSSZ) +/// This is the standard format required by LDAP for timestamp attributes like pwdChangedTime +pub fn to_generalized_time(dt: &NaiveDateTime) -> Vec { + chrono::Utc + .from_utc_datetime(dt) + .format("%Y%m%d%H%M%SZ") + .to_string() + .into_bytes() +} + fn make_dn_pair(mut iter: I) -> LdapResult<(String, String)> where I: Iterator, @@ -321,12 +331,6 @@ pub fn get_custom_attribute( attributes: &[Attribute], attribute_name: &AttributeName, ) -> Option>> { - let convert_date = |date| { - chrono::Utc - .from_utc_datetime(date) - .to_rfc3339() - .into_bytes() - }; attributes .iter() .find(|a| &a.name == attribute_name) @@ -352,9 +356,9 @@ pub fn get_custom_attribute( AttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => { l.iter().map(|p| p.clone().into_bytes()).collect() } - AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)], + AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![to_generalized_time(dt)], AttributeValue::DateTime(Cardinality::Unbounded(l)) => { - l.iter().map(convert_date).collect() + l.iter().map(to_generalized_time).collect() } }) } diff --git a/crates/ldap/src/search.rs b/crates/ldap/src/search.rs index 85cc4a1..d3fe278 100644 --- a/crates/ldap/src/search.rs +++ b/crates/ldap/src/search.rs @@ -875,7 +875,7 @@ mod tests { }, LdapPartialAttribute { atype: "createTimestamp".to_string(), - vals: vec![b"1970-01-01T00:00:00+00:00".to_vec()] + vals: vec![b"19700101000000Z".to_vec()] }, LdapPartialAttribute { atype: "entryUuid".to_string(), @@ -918,7 +918,7 @@ mod tests { }, LdapPartialAttribute { atype: "createTimestamp".to_string(), - vals: vec![b"2014-07-08T09:10:11+00:00".to_vec()] + vals: vec![b"20140708091011Z".to_vec()] }, LdapPartialAttribute { atype: "entryUuid".to_string(), @@ -1798,13 +1798,7 @@ mod tests { }, LdapPartialAttribute { atype: "createtimestamp".to_string(), - vals: vec![ - chrono::Utc - .timestamp_opt(0, 0) - .unwrap() - .to_rfc3339() - .into_bytes(), - ], + vals: vec![b"19700101000000Z".to_vec()], }, LdapPartialAttribute { atype: "entryuuid".to_string(), @@ -2107,4 +2101,59 @@ mod tests { ]), ); } + + #[tokio::test] + async fn test_pwd_changed_time_format() { + use chrono::prelude::*; + let mut mock = MockTestBackendHandler::new(); + let test_date = chrono::Utc + .with_ymd_and_hms(2024, 12, 31, 14, 30, 45) + .unwrap() + .naive_utc(); + + mock.expect_list_users().times(1).return_once(move |_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("testuser"), + email: "test@example.com".into(), + display_name: Some("Test User".to_string()), + uuid: uuid!("12345678-9abc-def0-1234-56789abcdef0"), + password_modified_date: test_date, + ..Default::default() + }, + groups: None, + }]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + + let request = make_search_request( + "ou=people,dc=example,dc=com", + LdapFilter::Equality("uid".into(), "testuser".into()), + vec!["pwdChangedTime"], + ); + + let result = ldap_handler.do_search_or_dse(&request).await.unwrap(); + + if let LdapOp::SearchResultEntry(entry) = &result[0] { + assert_eq!(entry.dn, "uid=testuser,ou=people,dc=example,dc=com"); + assert_eq!(entry.attributes.len(), 1); + + let pwd_changed_time_attr = &entry.attributes[0]; + assert_eq!(pwd_changed_time_attr.atype, "pwdChangedTime"); + assert_eq!(pwd_changed_time_attr.vals.len(), 1); + + let timestamp_str = std::str::from_utf8(&pwd_changed_time_attr.vals[0]) + .expect("Invalid UTF-8 in timestamp"); + + // Verify it's in GeneralizedTime format (YYYYMMDDHHMMSSZ) + assert_eq!(timestamp_str, "20241231143045Z"); + + // Verify the format can be parsed back correctly + let parsed_time = chrono::NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M%SZ") + .expect("Invalid GeneralizedTime format"); + assert_eq!(parsed_time, test_date); + } else { + panic!("Expected SearchResultEntry"); + } + } }