Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@tsaarni
Copy link
Contributor

@tsaarni tsaarni commented Nov 1, 2022

This change adds limited support for LDAP password policy control. The support is limited to prompting user to update their password when the LDAP server indicates that password must be changed. Previously Keycloak let the user in and ignored the mandatory password reset.

After applying this PR there is new optional setting "Enable LDAP password policy" in LDAP advanced settings. It defaults to false. When set to true the PasswordPolicyRequest control is sent during authentication as part of LDAP bind operation, when it is executed on user's behalf (but not for admin connection). The request is marked with criticality=FALSE which means that the server can ignore it if it does not support the LDAP control. If the server processes the control, it may respond with PasswordPolicyResponse with error code value changeAfterReset which is defined by the specification as following

  • bindResponse.resultCode = success (0), passwordPolicyResponse.error = changeAfterReset (2): The user is binding for the first time after the password administrator set the password. In this scenario, the client SHOULD prompt the user to change his password immediately.

With this the LDAP server indicates that the user must update their password. In this case UPDATE_PASSWORD required action is added for the user to trigger existing update password page. The approach follows similar implementation for MSAD.

Following limitations apply:

  • Federation must be configured with edit mode WRITABLE: federated users can only change their passwords when Keycloak is configured to synchronize attributes back to the LDAP server.
  • Federation must be configured with import users enabled: required user actions can be set only for users that exist in Keycloak database.
  • Keycloak changes user passwords using the LDAP administrator credentials configured in federation settings, rather than the user’s old credentials. In some LDAP servers, if pwdMustChange: TRUE is set and an administrator updates a user’s password, the server will set pwdReset: TRUE for that user, forcing another password change. This can cause an infinite reset loop. In current OpenLDAP versions, this behaviour occurs when the administrator has the manage ACL privilege on the password attribute. To prevent it, grant Keycloak’s LDAP account only the write privilege (not manage).

Forced password change is common use case in many organizations. Examples of LDAP servers that are known to support the Password Policy extension:

  • OpenLDAP
  • 389ds

Other uses of this control are beyond the scope of this PR. For example, in OpenLDAP, a custom password check module can enforce complexity rules during password changes, but only if the control is included in the password update request.

Fixes #14523

@tsaarni tsaarni changed the title Add limited support for LDAP password policy control Implement forced password change for LDAP federated user (password policy control) Nov 1, 2022
@tsaarni tsaarni force-pushed the ldap-forced-password-change branch 2 times, most recently from f5584dd to 06c23fe Compare November 2, 2022 16:14
@tsaarni tsaarni force-pushed the ldap-forced-password-change branch 4 times, most recently from e588628 to fee8b17 Compare November 15, 2022 15:59
@tsaarni
Copy link
Contributor Author

tsaarni commented Nov 16, 2022

If I understood correctly, LDAP map storage implementation is still not hooked into authentication, so ppolicy support cannot be copied fully to there at this point.

@tsaarni
Copy link
Contributor Author

tsaarni commented Nov 18, 2022

Just a friendly ping: this PR is ready for review! @mposolda would you have time to take a look, or other maintainers?

@tsaarni
Copy link
Contributor Author

tsaarni commented Jan 10, 2023

Pinging again. Any comments greatly appreciated!

@tsaarni tsaarni force-pushed the ldap-forced-password-change branch from 565b90a to 2fc6b4b Compare February 22, 2023 09:40
@tsaarni tsaarni requested a review from a team February 22, 2023 09:40
@tsaarni tsaarni requested review from a team as code owners February 22, 2023 09:40
@yu-jianshu
Copy link

🚀 🚀 🚀 This PR seems to be very useful and we exactly needs the feature for LDAP to pass the password expiry info to keycloak!
Hope maintainers can review this PR and merge it! 🙇

@tsaarni tsaarni force-pushed the ldap-forced-password-change branch 2 times, most recently from 68b3150 to bdf043c Compare November 21, 2023 13:15
@ghost ghost added the flaky-test label Nov 21, 2023
Copy link

@ghost ghost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreported flaky test detected, please review

@ghost
Copy link

ghost commented Nov 21, 2023

Unreported flaky test detected

If the below flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR.

org.keycloak.testsuite.x509.X509BrowserCRLTest#loginSuccessWithCRLSignedWithIntermediateCA3FromTruststore

Keycloak CI - FIPS IT (non-strict)

java.lang.RuntimeException: Could not create statement
	at org.jboss.arquillian.junit.Arquillian.methodBlock(Arquillian.java:313)
	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
...

Report flaky test

org.keycloak.testsuite.x509.X509BrowserCRLTest#loginFailedWithIntermediateRevocationListFromFile

Keycloak CI - FIPS IT (non-strict)

java.lang.RuntimeException: Could not create statement
	at org.jboss.arquillian.junit.Arquillian.methodBlock(Arquillian.java:313)
	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
...

Report flaky test

org.keycloak.testsuite.x509.X509BrowserCRLTest#loginFailedWithIntermediateRevocationListFromHttp

Keycloak CI - FIPS IT (non-strict)

java.lang.RuntimeException: Could not create statement
	at org.jboss.arquillian.junit.Arquillian.methodBlock(Arquillian.java:313)
	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
...

Report flaky test

org.keycloak.testsuite.x509.X509BrowserCRLTest#loginFailedWithInvalidSignatureCRL

Keycloak CI - FIPS IT (non-strict)

java.lang.RuntimeException: Could not create statement
	at org.jboss.arquillian.junit.Arquillian.methodBlock(Arquillian.java:313)
	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
...

Report flaky test

@tsaarni
Copy link
Contributor Author

tsaarni commented Nov 22, 2023

I've updated the PR to catch up the latest changes, including the removal of map-store. Also wanted to say that I'd still be grateful to receive reviews, and happy to implement any requests to help getting the feature included!

Copy link
Contributor

@pedroigor pedroigor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tsaarni Sorry for the delay. It looks like a great addition.

I would consider adding a (yet another) switch to enable the behavior you are introducing given how sensitive it is to support things across different vendors.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate why we are removing this line? There is an expectation that this should fail if Start TLS is not possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will need to check this again and come back to this

(This was long time ago but I vaguely remember something like this: after adding the LDAP control, it triggered different behavior from Java LDAP implementation and the dummy LDAP search was not required anymore to send StartTLS. Regardless what it was, when we add on/off switch for ppolicy control, this needs to be revisited anyways)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. If this is not breaking starttls, works for me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've now re-organized the code in a way that startTLS() method is purely for issuing start TLS extended operation and it has no "side effect" of triggering authentication, like the extra LDAP search ldapContext.lookup() did before. For authentication I've added explicit ldapContext.reconnect().

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about removing the lookup, as it was added here #6592 I think for security reasons. I'll check that.

Copy link
Contributor Author

@tsaarni tsaarni Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was originally added for security reasons. Coincidentally, it was something I discovered at the time and even got credited for my only "CVE worthy" finding so far 🎉 Also test cases were added back then to cover this exact scenario and they still are there today. Back in 2019, I worked on this bug together with @mposolda and another person who no longer seems to be involved with Keycloak or work at IBM / Red Hat, so I’ll leave him unmentioned, not to bother him 😀

In the current main, the ldapContext.lookup() call is the very first LDAP operation executed after connecting. Due to the way JDK’s LDAP implementation works, the first operation triggers an LDAP bind to authenticate the connection. The lookup / search is empty, but it is used for its side-effect. The bug back then was that ldapContext.lookup() was wrapped in a try ... catch, which ended up ignoring the exception that authentication depended on. The fix in #6592 was that lookup() was moved outside the try ... catch so that authentication errors would no longer be swallowed inside the startTLS() method.

In this PR, I’ve restructured the code so that startTLS() is strictly responsible for issuing the StartTLS extended operation but not sending LDAP bind yet. For authentication, I’ve added an explicit ldapContext.reconnect() call, which triggers the LDAP bind in a single place regardless of StartTLS being used for the connection or not. The underlying reason for this re-organization was that reconnect() allows adding controls to the LDAP bind, also when StartTLS is used, while lookup() does not allow the same for the "side-effect bind" that it triggers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of history here, and glad we have people like you helping us during this time :)

Thanks for the details.

@tsaarni
Copy link
Contributor Author

tsaarni commented Apr 26, 2024

@tsaarni Sorry for the delay. It looks like a great addition.

I would consider adding a (yet another) switch to enable the behavior you are introducing given how sensitive it is to support things across different vendors.

Great, I will refresh this PR ASAP!

I'll add switch to enable the LDAP control. Would it default to false? Just as comparison, SSSD has it enabled by default (link)

@tsaarni tsaarni force-pushed the ldap-forced-password-change branch from bdf043c to 3a5a80a Compare June 7, 2024 11:35
@tsaarni tsaarni requested review from a team as code owners June 7, 2024 11:35
@pedroigor
Copy link
Contributor

@sguilhen Can we have a third pair of eyes on this one?


@Override
public void close() {
if (vaultStringSecret != null) vaultStringSecret.close();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see calling close is basically setting the secret to null but I'm not sure if we want to remove calling it for the sake of respecting the expectations around vault secrets where we might want to clean-up resources for whatever reason.

Copy link
Contributor Author

@tsaarni tsaarni Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm suggesting not having vaultStringSecret as a member variable at all, therefore removal of close(). Though, in case of vaultStringSecret.close() was overwritten as an empty method, so I think it had no effect.

I'm suggesting removal of vaultStringSecret member since Optional complicated finding the bind password unnecessarily in several places. I'm suggesting having new method that handles that in single place and has the vault secret as a local variable:

    // Get bind password from vault or from directly from configuration, may be null.
    private String getBindPassword() {
        VaultStringSecret vaultSecret = session.vault().getStringSecret(ldapConfig.getBindCredential());
        return vaultSecret.get().orElse(ldapConfig.getBindCredential());
    }

@tsaarni
Copy link
Contributor Author

tsaarni commented Jun 10, 2024

@sguilhen Can we have a third pair of eyes on this one?

I'm having some failed tests that I've yet to understand, and therefore set this as Draft. But reviews are of course appreciated! 🙏

@sguilhen
Copy link
Contributor

@pedroigor can you re-run the failing test group? I don't think the failures are related to this PR at all

@tsaarni tsaarni force-pushed the ldap-forced-password-change branch from 3fcd984 to c1f364a Compare June 17, 2024 13:32
@tsaarni tsaarni marked this pull request as ready for review June 17, 2024 13:39
@tsaarni
Copy link
Contributor Author

tsaarni commented Jun 17, 2024

I've marked the PR as ready-for-review and updated the description to reflect the current state with the UI changes.

I've split the PR into two commits for clarity:

  • The purpose of the first commit is to avoid StartTLS from triggering implicit bind operation and use ldapContext.reconnect() instead when necessary
  • Second commit builds on top of this refactoring, using the explicit bind authCtx.reconnect(<ldap controls>) that allows passing custom LDAP controls

@sguilhen
Copy link
Contributor

The failing test needs to be checked - not sure how exactly it is related to this PR, but the failure has been consistent here.

@tsaarni
Copy link
Contributor Author

tsaarni commented Jun 18, 2024

The failing test needs to be checked - not sure how exactly it is related to this PR, but the failure has been consistent here.

I found that the error can be reproduced locally only when running testsuite with -Pauth-server-quarkus.

$ mvn clean install -f testsuite/integration-arquillian/pom.xml -Pauth-server-quarkus  -Dtest=org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest#testRequestCredential

...

[ERROR]   OID4VCIssuerEndpointTest.testRequestCredential:349 » RunOnServer java.lang.RuntimeException: > java.io.InvalidClassException: com.fasterxml.jackson.databind.cfg.MapperConfigBase; local class incompatible: stream classdesc serialVersionUID = 6477281625127050473, local class serialVersionUID = -3161289088523963015

The test case instantiates com.fasterxml.jackson.databind.ObjectMapper

If I understood it correctly, the code serializes the object and runs it in context of the server process, and it now fails like this

Caused by: java.lang.RuntimeException: java.io.InvalidClassException: com.fasterxml.jackson.databind.cfg.MapperConfigBase; local class incompatible: stream classdesc serialVersionUID = 6477281625127050473, local class serialVersionUID = -3161289088523963015
        at org.keycloak.testsuite.runonserver.SerializationUtil.decode(SerializationUtil.java:42)
        at org.keycloak.testsuite.rest.TestingResourceProvider.runOnServer(TestingResourceProvider.java:825)
        at org.keycloak.testsuite.rest.TestingResourceProvider$quarkusrestinvoker$runOnServer_ff89fab48911f9f930b958e942a03e8c3bccf56c.invoke(Unknown Source)
        at org.jboss.resteasy.reactive.server.handlers.InvocationHandler.handle(InvocationHandler.java:29)
        at io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext.invokeHandler(QuarkusResteasyReactiveRequestContext.java:141)
        at org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:147)
        at io.quarkus.vertx.core.runtime.VertxCoreRecorder$14.runWith(VertxCoreRecorder.java:582)
        at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2513)
        at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1538)
        at org.jboss.threads.DelegatingRunnable.run(DelegatingRunnable.java:29)
        at org.jboss.threads.ThreadLocalResettingRunnable.run(ThreadLocalResettingRunnable.java:29)
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        at java.base/java.lang.Thread.run(Thread.java:840) 

In this PR I've used wildfly-elytron for its ASN1 support. It was already used by Keycloak in crypto/elytron/(link)

<dependency>
<groupId>org.wildfly.security</groupId>
<artifactId>wildfly-elytron</artifactId>
</dependency>
so in my thinking I did NOT add a new dependency, I just added existing dependency as dependency to federation/ldap/pom.xml where it was not used before. Wildfly-elytron itself depends on jackson-databind (link)

I guess this now causes a conflict: jackson-databind version in testsuite is not the same as jackson-databind in server.

I'm not sure how to solve this. Keycloak itself does not seem to define the version of jackson-databind anywhere. depends on quarkus-bom to define jackson-databind version. I thought I could add <scope>provided</scope> to testsuite POMs, but that did not solve the conflict.

@sguilhen
Copy link
Contributor

@pedroigor any ideas about this test?

@ssilvert ssilvert self-assigned this Jun 25, 2024
ssilvert
ssilvert previously approved these changes Jun 25, 2024
Copy link
Contributor

@ssilvert ssilvert left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving the UI part.

@tsaarni
Copy link
Contributor Author

tsaarni commented Jun 26, 2024

To fix #15253 (comment) I added hand-written ASN1 BER decoding to replace dependency to wildfly-elytron decoder 91826d1. This is bit similar approach previously taken here in PasswordModifyRequest.java though it is encoding, not decoding, and it did not attempt to add (base for) "generic" implementation.

@tsaarni
Copy link
Contributor Author

tsaarni commented Aug 12, 2025

I’ve rebased and fixed this PR so it’s up to date with main, in case there’s interest in this feature. I’ve also updated the description with my experiences with OpenLDAP.

@tsaarni tsaarni force-pushed the ldap-forced-password-change branch from 2498510 to 679a98c Compare August 15, 2025 06:47
@sguilhen sguilhen requested a review from pedroigor August 15, 2025 11:52
@pedroigor
Copy link
Contributor

I'll add switch to enable the LDAP control. Would it default to false? Just as comparison, SSSD has it enabled by default (link)

Yeah, it should default to false.

@tsaarni tsaarni force-pushed the ldap-forced-password-change branch from 679a98c to fe68dc8 Compare November 3, 2025 11:13
@tsaarni
Copy link
Contributor Author

tsaarni commented Nov 3, 2025

I’ve rebased the PR to the latest main branch. I’d appreciate your review and any ideas on how to get this moving again. Thank You!

This commit contains following changes

1. Refactored StartTLS with LDAP.

Refactor initialization of LDAP context into explicit separate steps:

a. Initialize context with LDAP connection properties only
b. Optionally send StartTLS extended request
c. Send bind

Previously both connection and authentication properties were set together
which triggers implicit authentication in the JDK LDAP and it happened
differently depending if StartTLS was used or not.

This change moves bind into explicit context.reconnect() call, which also
makes is possible in future to send LDAP control messages with bind.
That is not possible with implicit bind.

2. Add limited support for LDAP password policy control

Send password policy control during LDAP bind operation and receive response
according to chapter 9.1 of [1].  When server responds with error
"changeAfterReset" then prompt user to update their password by adding
UPDATE_PASSWORD required action

Following preconditions need to be met:

- import mode enabled, otherwise required actions cannot be persisted.
- edit mode is set writable, so that users can modify their passwords.

Without these preconditions changeAfterReset is treated as failed
authentication.

3. Implement ASN1 decoding for ppolicy control

[1] https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11

Signed-off-by: Tero Saarni <[email protected]>
@tsaarni tsaarni force-pushed the ldap-forced-password-change branch from 6910f95 to 4ca41fb Compare January 28, 2026 18:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for enforced password change with LDAP federation

5 participants