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
2025-09-22 00:42:51 +02:00
committed by GitHub
parent 8a803bfb11
commit 84fb9b0fd2
3 changed files with 82 additions and 38 deletions
+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");
}
}
}