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"); + } + } }