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.
This commit is contained in:
Copilot authored and GitHub committed 2025-09-22 00:42:51 +02:00
1 parent 8a803bfb11
commit 84fb9b0fd2
3 files changed
+82 -38

No files matched your search

+11 -20
View File
@@ -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,
+13 -9
View File
@@ -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<u8> {
chrono::Utc
.from_utc_datetime(dt)
.format("%Y%m%d%H%M%SZ")
.to_string()
.into_bytes()
}
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
where
I: Iterator<Item = String>,
@@ -321,12 +331,6 @@ pub fn get_custom_attribute(
attributes: &[Attribute],
attribute_name: &AttributeName,
) -> Option<Vec<Vec<u8>>> {
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()
}
})
}
+58 -9
View File
@@ -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");
}
}
}