Implement forced password change for LDAP federated user (password policy control) (#15253)

* Add limited support for LDAP password policy control

Signed-off-by: Tero Saarni <tero.saarni@est.tech>
This commit is contained in:
Tero Saarni
2026-02-20 14:15:51 +02:00
committed by GitHub
parent 2b47045369
commit 128384ca15
19 changed files with 737 additions and 75 deletions
@@ -152,6 +152,7 @@ This mapper grants a specified {project_name} role to each {project_name} user f
Group Mapper::
This mapper maps LDAP groups from a branch of an LDAP tree into groups within {project_name}. This mapper also propagates user-group mappings from LDAP into user-group mappings in {project_name}.
[[_msad_mapper]]
MSAD User Account Mapper::
This mapper is specific to Microsoft Active Directory (MSAD). It can integrate the MSAD user account state into the {project_name} account state, such as enabled account or expired password. This mapper uses the `userAccountControl`, and `pwdLastSet` LDAP attributes, specific to MSAD and are not the LDAP standard. For example, if the value of `pwdLastSet` is `0`, the {project_name} user must update their password. The result is an UPDATE_PASSWORD required action added to the user. If the value of `userAccountControl` is `514` (disabled account), the {project_name} user is disabled.
@@ -170,6 +171,30 @@ By default, LDAP servers such as MSAD, RHDS, or FreeIPA hash and salt passwords.
WARNING: Always verify that user passwords are properly hashed and not stored as plaintext by inspecting a changed
directory entry using `ldapsearch` and base64 decode the `userPassword` attribute value.
[[_ldap_password_policy]]
==== Enabling password change after reset
You can force users to change their password after an administrator resets their password. This feature is useful for
security reasons because it prevents users from using the temporary password set by the administrator for a long time.
For that, enable the `Enable LDAP password policy` setting so that a `UPDATE_PASSWORD` required action will be added to
the user whenever the LDAP server indicates that the user must update their password.
{project_name} is usually configured to connect to the LDAP server using an administrator account even when users are
updating their password. In this case, some LDAP servers, if not configured properly, will always force a password change
after the user changes their password because the LDAP server will see that the password was changed by an administrator
account and not by the user itself.
When enabling this feature, make sure your LDAP server is configured to not force users to change their password a
second time after the user changes their password. Not doing so might cause a password change loop where the user is
forced to change their password every time they log in.
[NOTE]
====
This capability is based on https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11[Password Policy for LDAP Directories (IETF draft-behera-ldap-password-policy)].
For Microsoft Active Directory, use the <<_msad_mapper,MSAD User Account Mapper>> instead.
====
[[_ldap_connection_pool]]
==== Configuring the connection pool
@@ -243,6 +268,3 @@ with your LDAP server.
in your environment. For example if login of some user takes lot of time, you can consider attach his LDAP entry showing count of `member` attributes
of various "group" entries. In this case, it might be useful to add if those group entries are mapped to some Group LDAP mapper (or Role LDAP Mapper)
in {project_name} and so on.
@@ -199,8 +199,8 @@ public class LDAPServerCapabilitiesManager {
// is not needed anymore
try (LDAPContextManager ldapContextManager = LDAPContextManager.create(session, ldapConfig)) {
LdapContext ldapContext = ldapContextManager.getLdapContext();
if (TEST_AUTHENTICATION.equals(config.getAction()) && LDAPConstants.AUTH_TYPE_NONE.equals(config.getAuthType())) {
// reconnect to force an anonymous bind operation
if (TEST_AUTHENTICATION.equals(config.getAction())) {
// Reconnect to force bind operation.
ldapContext.reconnect(null);
}
} catch (Exception ne) {
@@ -257,6 +257,11 @@ public class LDAPConfig {
return config.getFirst(LDAPConstants.REFERRAL);
}
public boolean isEnableLdapPasswordPolicy() {
String enableLdapPasswordPolicy = config.getFirst(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY);
return Boolean.parseBoolean(enableLdapPasswordPolicy);
}
public void addBinaryAttribute(String attrName) {
binaryAttributeNames.add(attrName);
}
@@ -69,6 +69,7 @@ import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.utils.ReadOnlyUserModelDelegate;
import org.keycloak.policy.PasswordPolicyManagerProvider;
import org.keycloak.policy.PolicyError;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.storage.DatastoreProvider;
import org.keycloak.storage.ReadOnlyException;
import org.keycloak.storage.StorageId;
@@ -85,6 +86,7 @@ import org.keycloak.storage.ldap.idm.query.Condition;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQueryConditionsBuilder;
import org.keycloak.storage.ldap.idm.store.ldap.LDAPIdentityStore;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyPasswordChangeException;
import org.keycloak.storage.ldap.kerberos.LDAPProviderKerberosConfig;
import org.keycloak.storage.ldap.mappers.LDAPMappersComparator;
import org.keycloak.storage.ldap.mappers.LDAPOperationDecorator;
@@ -822,6 +824,30 @@ public class LDAPStorageProvider implements UserStorageProvider,
try {
ldapIdentityStore.validatePassword(ldapUser, password);
return true;
} catch (PasswordPolicyPasswordChangeException e) {
// LDAP password policy requires a forced password change.
// Check for edit mode writable, so that user can modify LDAP password.
if (editMode != EditMode.WRITABLE) {
logger.debugf("User '%s' in realm '%s' is forced to change password but editMode is not writable. Failing login.", user.getUsername(), realm.getName());
return false;
}
if (user.getRequiredActionsStream()
.noneMatch(action -> Objects.equals(action, UserModel.RequiredAction.UPDATE_PASSWORD.name()))) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
if (authSession != null) {
if (authSession.getRequiredActions().stream().noneMatch(action -> Objects.equals(action, UserModel.RequiredAction.UPDATE_PASSWORD.name()))) {
logger.debugf("Adding requiredAction UPDATE_PASSWORD to the authenticationSession of user %s", user.getUsername());
authSession.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
}
} else {
// Just a fallback. It should not happen during normal authentication process
logger.debugf("Adding requiredAction UPDATE_PASSWORD to the user %s", user.getUsername());
user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
}
} else {
logger.tracef("Skip adding required action UPDATE_PASSWORD. It was already set on user '%s' in realm '%s'", user.getUsername(), realm.getName());
}
return true;
} catch (AuthenticationException ae) {
AtomicReference<Boolean> processed = new AtomicReference<>(false);
realm.getComponentsStream(model.getId(), LDAPStorageMapper.class.getName())
@@ -223,6 +223,10 @@ public class LDAPStorageProviderFactory implements UserStorageProviderFactory<LD
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue("false")
.add()
.property().name(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY)
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue("false")
.add()
.build();
}
@@ -0,0 +1,136 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.ldap.idm.store.ldap;
import java.io.IOException;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
/**
* A decoder for the ASN.1 BER encoding.
*
* Very limited implementation, only supports what is needed by the current LDAP extension controls.
*/
public class BERDecoder {
// Universal tags.
public static final int TAG_SEQUENCE = 0x30;
// Tag classes.
public static final int TAG_CLASS_CONTEXT_SPECIFIC = 0x80;
// Tag forms.
public static final int TAG_FORM_PRIMITIVE = 0x00;
private ByteBuffer encoded;
public BERDecoder(byte[] encodedValue) {
this.encoded = ByteBuffer.wrap(encodedValue);
}
/**
* Start decoding a sequence.
*/
public void startSequence() throws DecodeException {
try {
byte tag = encoded.get();
if (tag != TAG_SEQUENCE) {
throw new DecodeException("Expected SEQUENCE (" + TAG_SEQUENCE + ") but got " + tag);
}
readLength();
} catch (BufferUnderflowException e) {
throw new DecodeException("Unexpected end of input");
}
}
/**
* Check if the next element matches with the given tag, but do not consume it.
* Returns false if there is no more data.
*/
public boolean isNextTag(int clazz, int form, int tag) {
// Check if there is more data to read (e.g. to allow empty SEQUENCE).
if (!encoded.hasRemaining()) {
return false;
}
encoded.mark();
try {
int expected = clazz | form | tag;
int unsignedTag = encoded.get() & 0xFF;
return unsignedTag == expected;
} finally {
encoded.reset();
}
}
/**
* Skip over the next element.
*/
public void skipElement() throws DecodeException {
try {
encoded.get(); // Consume tag.
int length = readLength();
encoded.position(encoded.position() + length);
} catch (BufferUnderflowException e) {
throw new DecodeException("Unexpected end of input");
}
}
/**
* Drain the value bytes of the next element.
*/
public byte[] drainElementValue() throws DecodeException {
try {
encoded.get(); // Consume tag.
int length = readLength();
byte[] value = new byte[length];
encoded.get(value);
return value;
} catch (BufferUnderflowException e) {
throw new DecodeException("Unexpected end of input");
}
}
private int readLength() throws DecodeException {
int length = encoded.get() & 0xFF;
// Short form.
if ((length & 0x80) == 0) {
return length;
}
// Long form.
int numBytes = length & 0x7F;
if (numBytes > 4) {
throw new DecodeException("Cannot handle more than 4 bytes of length, got " + numBytes + " bytes");
}
length = 0;
for (int i = 0; i < numBytes; i++) {
length = (length << 8) | (encoded.get() & 0xFF);
}
return length;
}
public static final class DecodeException extends IOException {
DecodeException(String message) {
super(message);
}
}
}
@@ -4,7 +4,6 @@ import java.io.IOException;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import javax.naming.AuthenticationException;
import javax.naming.Context;
@@ -35,19 +34,6 @@ public final class LDAPContextManager implements AutoCloseable {
private final KeycloakSession session;
private final LDAPConfig ldapConfig;
private StartTlsResponse tlsResponse;
private VaultStringSecret vaultStringSecret = new VaultStringSecret() {
@Override
public Optional<String> get() {
return Optional.empty();
}
@Override
public void close() {
}
};
private LdapContext ldapContext;
public LDAPContextManager(KeycloakSession session, LDAPConfig connectionProperties) {
@@ -59,26 +45,22 @@ public final class LDAPContextManager implements AutoCloseable {
return new LDAPContextManager(session, connectionProperties);
}
// Create connection that is authenticated as admin user.
private void createLdapContext() throws NamingException {
var tracing = session.getProvider(TracingProvider.class);
tracing.startSpan(LDAPContextManager.class, "createLdapContext");
try {
Hashtable<Object, Object> connProp = getConnectionProperties(ldapConfig);
if (!LDAPConstants.AUTH_TYPE_NONE.equals(ldapConfig.getAuthType())) {
vaultStringSecret = getVaultSecret();
if (vaultStringSecret != null && !ldapConfig.isStartTls() && ldapConfig.getBindCredential() != null) {
connProp.put(SECURITY_CREDENTIALS, vaultStringSecret.get()
.orElse(ldapConfig.getBindCredential()).toCharArray());
}
}
// Create the LDAP context without setting the security principal and credentials yet.
// This avoids triggering an automatic bind request, allowing us to send an optional StartTLS request before binding.
Hashtable<Object, Object> connProp = getNonAuthConnectionProperties(ldapConfig);
if (ldapConfig.isConnectionTrace()) {
connProp.put(LDAPConstants.CONNECTION_TRACE_BER, System.err);
}
ldapContext = new SessionBoundInitialLdapContext(session, connProp, null);
// Send StartTLS request and setup SSL context if needed.
if (ldapConfig.isStartTls()) {
SSLSocketFactory sslSocketFactory = null;
if (LDAPUtil.shouldUseTruststoreSpi(ldapConfig)) {
@@ -86,8 +68,7 @@ public final class LDAPContextManager implements AutoCloseable {
sslSocketFactory = provider.getSSLSocketFactory();
}
tlsResponse = startTLS(ldapContext, ldapConfig.getAuthType(), ldapConfig.getBindDN(),
vaultStringSecret.get().orElse(ldapConfig.getBindCredential()), sslSocketFactory);
tlsResponse = startTLS(ldapContext, sslSocketFactory);
// Exception should be already thrown by LDAPContextManager.startTLS if "startTLS" could not be established, but rather do some additional check
if (tlsResponse == null) {
@@ -100,6 +81,11 @@ public final class LDAPContextManager implements AutoCloseable {
} finally {
tracing.endSpan();
}
setAdminConnectionAuthProperties(ldapContext);
// Bind will be automatically called when operations are executed on the context,
// or it can be explicitly called by invoking the reconnect() method (e.g., authentication test in LDAPServerCapabilitiesManager.testLDAP()).
}
public LdapContext getLdapContext() throws NamingException {
@@ -108,25 +94,18 @@ public final class LDAPContextManager implements AutoCloseable {
return ldapContext;
}
private VaultStringSecret getVaultSecret() {
return LDAPConstants.AUTH_TYPE_NONE.equals(ldapConfig.getAuthType())
? null
: session.vault().getStringSecret(ldapConfig.getBindCredential());
// 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());
}
public static StartTlsResponse startTLS(LdapContext ldapContext, String authType, String bindDN, String bindCredential, SSLSocketFactory sslSocketFactory) throws NamingException {
public static StartTlsResponse startTLS(LdapContext ldapContext, SSLSocketFactory sslSocketFactory) throws NamingException {
StartTlsResponse tls = null;
try {
tls = (StartTlsResponse) ldapContext.extendedOperation(new StartTlsRequest());
tls.negotiate(sslSocketFactory);
ldapContext.addToEnvironment(Context.SECURITY_AUTHENTICATION, authType);
if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) {
ldapContext.addToEnvironment(Context.SECURITY_PRINCIPAL, bindDN);
ldapContext.addToEnvironment(Context.SECURITY_CREDENTIALS, bindCredential != null ? bindCredential.toCharArray() : null);
}
} catch (Exception e) {
logger.error("Could not negotiate TLS", e);
NamingException ne = new AuthenticationException("Could not negotiate TLS");
@@ -134,44 +113,33 @@ public final class LDAPContextManager implements AutoCloseable {
throw ne;
}
// throws AuthenticationException when authentication fails
ldapContext.lookup("");
return tls;
}
// Get connection properties of admin connection
private Hashtable<Object, Object> getConnectionProperties(LDAPConfig ldapConfig) {
Hashtable<Object, Object> env = getNonAuthConnectionProperties(ldapConfig);
// Fill in the connection properties to authenticate as admin.
private void setAdminConnectionAuthProperties(LdapContext ldapContext) throws NamingException {
String authType = ldapConfig.getAuthType();
if (authType != null) {
ldapContext.addToEnvironment(Context.SECURITY_AUTHENTICATION, authType);
}
if(!ldapConfig.isStartTls()) {
String authType = ldapConfig.getAuthType();
String bindPassword = getBindPassword();
if (bindPassword != null) {
ldapContext.addToEnvironment(SECURITY_CREDENTIALS, bindPassword);
}
if (authType != null) env.put(Context.SECURITY_AUTHENTICATION, authType);
String bindDN = ldapConfig.getBindDN();
char[] bindCredential = null;
if (ldapConfig.getBindCredential() != null) {
bindCredential = ldapConfig.getBindCredential().toCharArray();
}
if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) {
if (bindDN != null) env.put(Context.SECURITY_PRINCIPAL, bindDN);
if (bindCredential != null) env.put(Context.SECURITY_CREDENTIALS, bindCredential);
}
String bindDN = ldapConfig.getBindDN();
if (bindDN != null) {
ldapContext.addToEnvironment(Context.SECURITY_PRINCIPAL, bindDN);
}
if (logger.isDebugEnabled()) {
Map<Object, Object> copyEnv = new Hashtable<>(env);
Map<Object, Object> copyEnv = new Hashtable<>(ldapContext.getEnvironment());
if (copyEnv.containsKey(Context.SECURITY_CREDENTIALS)) {
copyEnv.put(Context.SECURITY_CREDENTIALS, "**************************************");
}
logger.debugf("Creating LdapContext using properties: [%s]", copyEnv);
}
return env;
}
@@ -251,7 +219,6 @@ public final class LDAPContextManager implements AutoCloseable {
@Override
public void close() {
if (vaultStringSecret != null) vaultStringSecret.close();
if (tlsResponse != null) {
try {
tlsResponse.close();
@@ -36,6 +36,7 @@ import javax.naming.directory.DirContext;
import javax.naming.directory.ModificationItem;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.BasicControl;
import javax.naming.ldap.Control;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.LdapContext;
@@ -54,6 +55,9 @@ import org.keycloak.storage.ldap.idm.model.LDAPDn;
import org.keycloak.storage.ldap.idm.query.Condition;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQueryConditionsBuilder;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyControl;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyControlFactory;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyPasswordChangeException;
import org.keycloak.storage.ldap.idm.store.ldap.extended.PasswordModifyRequest;
import org.keycloak.storage.ldap.mappers.LDAPOperationDecorator;
import org.keycloak.tracing.TracingProvider;
@@ -497,13 +501,14 @@ public class LDAPOperationManager {
// Never use connection pool to prevent password caching
env.put("com.sun.jndi.ldap.connect.pool", "false");
if(!this.config.isStartTls()) {
env.put(Context.SECURITY_AUTHENTICATION, "simple");
env.put(Context.SECURITY_PRINCIPAL, dn.toString());
env.put(Context.SECURITY_CREDENTIALS, password);
}
// Prepare to receive password policy response control.
env.put(LdapContext.CONTROL_FACTORIES, PasswordPolicyControlFactory.class.getName());
// Create connection but avoid triggering automatic bind request by not setting security principal and credentials yet.
// That allows us to send optional StartTLS request before binding.
authCtx = new InitialLdapContext(env, null);
// Send StartTLS request and setup SSL context if needed.
if (config.isStartTls()) {
SSLSocketFactory sslSocketFactory = null;
if (LDAPUtil.shouldUseTruststoreSpi(config)) {
@@ -511,13 +516,36 @@ public class LDAPOperationManager {
sslSocketFactory = provider.getSSLSocketFactory();
}
tlsResponse = LDAPContextManager.startTLS(authCtx, "simple", dn.toString(), password, sslSocketFactory);
tlsResponse = LDAPContextManager.startTLS(authCtx, sslSocketFactory);
// Exception should be already thrown by LDAPContextManager.startTLS if "startTLS" could not be established, but rather do some additional check
if (tlsResponse == null) {
throw new AuthenticationException("Null TLS Response returned from the authentication");
}
}
// Configure given credentials.
authCtx.addToEnvironment(Context.SECURITY_AUTHENTICATION, "simple");
authCtx.addToEnvironment(Context.SECURITY_PRINCIPAL, dn.toString());
authCtx.addToEnvironment(Context.SECURITY_CREDENTIALS, password);
// Send bind request. Throws AuthenticationException when authentication fails.
authCtx.reconnect(getControls());
// Check for password policy response control in the response.
// If present and forced password change is required, throw an exception.
Control[] responseControls = authCtx.getResponseControls();
if (responseControls != null) {
for (Control control : responseControls) {
if (control instanceof PasswordPolicyControl) {
PasswordPolicyControl response = (PasswordPolicyControl) control;
if (response.changeAfterReset()) {
throw new PasswordPolicyPasswordChangeException();
}
}
}
}
} catch (AuthenticationException ae) {
if (logger.isDebugEnabled()) {
logger.debugf(ae, "Authentication failed for DN [%s]", dn);
@@ -660,6 +688,14 @@ public class LDAPOperationManager {
}
}
private Control[] getControls() {
// If enabled, send a passwordPolicyRequest control as non-critical.
if (config.isEnableLdapPasswordPolicy()) {
return new Control[] { new BasicControl(PasswordPolicyControl.OID, false, null) };
}
return null;
}
private String getUuidAttributeName() {
return this.config.getUuidLDAPAttributeName();
}
@@ -0,0 +1,103 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.ldap.idm.store.ldap.control;
import java.math.BigInteger;
import javax.naming.ldap.Control;
import org.keycloak.storage.ldap.idm.store.ldap.BERDecoder;
import org.jboss.logging.Logger;
/**
* Implements (parts of) draft-behera-ldap-password-policy
*/
public class PasswordPolicyControl implements Control {
/* https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11#section-6.1 */
public static final String OID = "1.3.6.1.4.1.42.2.27.8.5.1";
private static final Logger logger = Logger.getLogger(PasswordPolicyControl.class);
private static final int ERROR_CHANGE_AFTER_RESET = 2;
private boolean changeAfterReset;
/*
* https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11#section-6.2
*
* PasswordPolicyResponseValue ::= SEQUENCE {
* warning [0] CHOICE {
* timeBeforeExpiration [0] INTEGER (0 .. maxInt),
* graceAuthNsRemaining [1] INTEGER (0 .. maxInt) } OPTIONAL,
* error [1] ENUMERATED {
* passwordExpired (0),
* accountLocked (1),
* changeAfterReset (2),
* passwordModNotAllowed (3),
* mustSupplyOldPassword (4),
* insufficientPasswordQuality (5),
* passwordTooShort (6),
* passwordTooYoung (7),
* passwordInHistory (8),
* passwordTooLong (9) } OPTIONAL }
*/
PasswordPolicyControl(byte[] encodedValue) {
if (encodedValue == null || encodedValue.length == 0) {
return;
}
BERDecoder ber = new BERDecoder(encodedValue);
try {
ber.startSequence(); // PasswordPolicyResponseValue ::= SEQUENCE
if (ber.isNextTag(BERDecoder.TAG_CLASS_CONTEXT_SPECIFIC, BERDecoder.TAG_FORM_PRIMITIVE, 0)) { // warning [0] CHOICE
ber.skipElement();
}
if (ber.isNextTag(BERDecoder.TAG_CLASS_CONTEXT_SPECIFIC, BERDecoder.TAG_FORM_PRIMITIVE, 1)) { // error [1] ENUMERATED
int error = new BigInteger(ber.drainElementValue()).intValue();
this.changeAfterReset = error == ERROR_CHANGE_AFTER_RESET;
}
} catch (BERDecoder.DecodeException ignored) {
logger.errorf("Failed to parse PasswordPolicyResponseValue: %s", ignored.getMessage());
}
}
public boolean changeAfterReset() {
return changeAfterReset;
}
@Override
public String getID() {
return OID;
}
@Override
public boolean isCritical() {
return Control.NONCRITICAL;
}
@Override
public byte[] getEncodedValue() {
return new byte[0];
}
}
@@ -0,0 +1,34 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.ldap.idm.store.ldap.control;
import javax.naming.NamingException;
import javax.naming.ldap.Control;
import javax.naming.ldap.ControlFactory;
public class PasswordPolicyControlFactory extends ControlFactory {
@Override
public Control getControlInstance(Control ctl) throws NamingException {
if (ctl.getID().equals(PasswordPolicyControl.OID)) {
return new PasswordPolicyControl(ctl.getEncodedValue());
}
return null;
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.ldap.idm.store.ldap.control;
import javax.naming.AuthenticationException;
/**
* PasswordPolicyPasswordChangeException is thrown when LDAP password policy response control indicates error "changeAfterReset".
*/
public class PasswordPolicyPasswordChangeException extends AuthenticationException {
public PasswordPolicyPasswordChangeException() {
super();
}
}
@@ -0,0 +1,78 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.ldap.idm.store.ldap.control;
import org.junit.Assert;
import org.junit.Test;
public class PasswordPolicyControlTest {
@Test
public void testDecodeResponseValue() {
// SEQUENCE { error changeAfterReset(2) }
PasswordPolicyControl control = new PasswordPolicyControl(new byte[] { 0x30, 0x03, (byte) 0x81, 0x01, 0x02 });
Assert.assertTrue(control.changeAfterReset());
}
@Test
public void testDecodeResponseValueWithWarningAndError() {
// SEQUENCE { warning timeBeforeExpiration(5), error changeAfterReset(2) }
// Note: we don't currently support warning, but at least make sure it doesn't break decoding.
PasswordPolicyControl control = new PasswordPolicyControl(new byte[] { 0x30, 0x08, (byte) 0x80, 0x02, 0x01, 0x05, (byte) 0x81, 0x01, 0x02 });
Assert.assertTrue(control.changeAfterReset());
}
@Test
public void testDecodeEmptySequence() {
// SEQUENCE {} (no warning and no error)
PasswordPolicyControl control = new PasswordPolicyControl(new byte[] { 0x30, 0x00 });
Assert.assertFalse(control.changeAfterReset());
}
@Test
public void testDecodeNullValue() {
PasswordPolicyControl control = new PasswordPolicyControl(null);
Assert.assertFalse(control.changeAfterReset());
}
@Test
public void testDecodeEmptyValue() {
PasswordPolicyControl control = new PasswordPolicyControl(new byte[] {});
Assert.assertFalse(control.changeAfterReset());
}
@Test
public void testDecodeErrors() {
// Not a sequence.
new PasswordPolicyControl(new byte[] { 0x31, 0x02, (byte) 0x81, 0x01, 0x02 });
// Sequence with invalid length.
new PasswordPolicyControl(new byte[] { 0x30, (byte) 0xFF, (byte) 0x82, 0x01, 0x02 });
// Sequence payload shorter than indicated.
new PasswordPolicyControl(new byte[] { 0x30, 0x03 });
// Sequence payload longer than indicated.
new PasswordPolicyControl(new byte[] { 0x30, 0x03, (byte) 0x81, 0x01, 0x02, 0x00, 0x00 });
// Invalid CHOICE tag.
new PasswordPolicyControl(new byte[] { 0x30, 0x03, (byte) 0x82, 0x01, 0x02 });
}
}
@@ -2827,6 +2827,8 @@ rolesHelp=Select the roles you want to associate with the selected user.
samlEntityDescriptor=SAML entity descriptor
passwordPolicyHintsEnabled=Password policy hints enabled
enableLdapv3PasswordHelp=Use the LDAPv3 Password Modify Extended Operation (RFC-3062). The password modify extended operation usually requires that LDAP user already has password in the LDAP server. So when this is used with 'Sync Registrations', it can be good to add also 'Hardcoded LDAP attribute mapper' with randomly generated initial password.
enableLdapPasswordPolicy=Enable LDAP password policy
enableLdapPasswordPolicyHelp=Use the LDAP password policy as outlined in IETF draft-behera-ldap-password-policy. When this option is enabled, users will be prompted to change their password upon logging in if the server indicates that a password change is necessary.
syncMode=Sync mode
details=Details
privateRSAKeyHelp=Private RSA Key encoded in PEM format
@@ -122,6 +122,36 @@ export const LdapSettingsAdvanced = ({
></Controller>
</FormGroup>
<FormGroup
label={t("enableLdapPasswordPolicy")}
labelIcon={
<HelpItem
helpText={t("enableLdapPasswordPolicyHelp")}
fieldLabelId="enableLdapPasswordPolicy"
/>
}
fieldId="kc-enable-ldap-password-policy"
hasNoPaddingTop
>
<Controller
name="config.enableLdapPasswordPolicy"
defaultValue={["false"]}
control={form.control}
render={({ field }) => (
<Switch
id={"kc-enable-ldap-password-policy"}
data-testid="ldap-password-policy"
isDisabled={false}
onChange={(_event, value) => field.onChange([`${value}`])}
isChecked={field.value[0] === "true"}
label={t("on")}
labelOff={t("off")}
aria-label={t("enableLdapPasswordPolicy")}
/>
)}
></Controller>
</FormGroup>
<FormGroup
label={t("trustEmail")}
labelIcon={
@@ -144,6 +144,7 @@ public class LDAPConstants {
public static final String LDAP_MATCHING_RULE_IN_CHAIN = ":1.2.840.113556.1.4.1941:";
public static final String REFERRAL = "referral";
public static final String ENABLE_LDAP_PASSWORD_POLICY = "enableLdapPasswordPolicy";
public static final String CONNECTION_TRACE = "connectionTrace";
@@ -161,6 +161,32 @@ public class LDAPRule extends ExternalResource {
break;
}
}
Annotation passwordPolicyAnnotations = description.getAnnotation(LDAPPasswordPolicy.class);
if (passwordPolicyAnnotations != null) {
LDAPPasswordPolicy passwordPolicy = (LDAPPasswordPolicy) passwordPolicyAnnotations;
log.debugf("Enabling LDAP password policy: mustChange=%s.", passwordPolicy.mustChange());
defaultProperties.setProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_ENABLED, "true");
defaultProperties.setProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_MUST_CHANGE, String.valueOf(passwordPolicy.mustChange()));
if (passwordPolicy.mustChange()) {
// Workaround for password policy behavior:
//
// Issue: When embedded LDAP server (ApacheDS) receives a password change request
// from an admin account, it sets pwdReset=TRUE. Since Keycloak uses an admin account
// for any password change, this creates an infinite loop where users are repeatedly
// forced to change their password after each login.
//
// Workaround: For mustChange password policy tests, disable access control on the
// embedded LDAP server. This allows Keycloak to authenticate as a regular user
// instead of admin, preventing the pwdReset flag from being automatically set on
// password changes. The bind DN is overridden in getConfig().
defaultProperties.setProperty(LDAPEmbeddedServer.PROPERTY_ENABLE_ACCESS_CONTROL, "false");
}
}
return super.apply(base, description);
}
@@ -261,6 +287,18 @@ public class LDAPRule extends ExternalResource {
// Configure the LDAP server to accept not secured connections from clients by default
System.setProperty("PROPERTY_SET_CONFIDENTIALITY_REQUIRED", "false");
}
switch (defaultProperties.getProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_ENABLED, "false")) {
case "true":
config.put(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY, "true");
break;
default:
config.put(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY, "false");
}
if (defaultProperties.getProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_MUST_CHANGE) != null) {
// See Workaround for password policy behavior comment in apply() for why a non-admin bind DN is needed.
config.put(LDAPConstants.BIND_DN, "uid=keycloak-admin," + config.get(LDAPConstants.BASE_DN));
config.put(LDAPConstants.BIND_CREDENTIAL, "secret");
}
return config;
}
@@ -312,4 +350,10 @@ public class LDAPRule extends ExternalResource {
public boolean isEmbeddedServer() {
return ldapTestConfiguration.isStartEmbeddedLdapServer();
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LDAPPasswordPolicy {
public boolean mustChange() default false;
}
}
@@ -0,0 +1,86 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.testsuite.federation.ldap;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.storage.ldap.idm.model.LDAPObject;
import org.keycloak.testsuite.util.LDAPRule;
import org.keycloak.testsuite.util.LDAPRule.LDAPPasswordPolicy;
import org.keycloak.testsuite.util.LDAPTestConfiguration;
import org.keycloak.testsuite.util.LDAPTestUtils;
import org.junit.Rule;
import org.junit.Test;
public class LDAPPasswordPolicyTest extends AbstractLDAPTest {
@Rule
// Start an embedded LDAP server with configuration derived from test annotations before each test.
public LDAPRule ldapRule = new LDAPRule()
.assumeTrue((LDAPTestConfiguration ldapConfig) -> {
return ldapConfig.isStartEmbeddedLdapServer();
});
@Override
protected LDAPRule getLDAPRule() {
return ldapRule;
}
@Override
protected void afterImportTestRealm() {
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();
LDAPObject user = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "mustchange", "John", "Doe",
"john_old@email.org", null, "1234");
LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), user, "Password1");
});
ldapRule.getLdapEmbeddedServer().setPwdReset("uid=mustchange,ou=People,dc=keycloak,dc=org", true);
}
@Test
@LDAPPasswordPolicy(mustChange=true)
public void testForcedPasswordChangeAfterReset() throws Exception {
// Login with user that has to change password.
loginPage.open();
loginPage.login("mustchange", "Password1");
// Forced password change sends user to update password page.
passwordUpdatePage.assertCurrent();
// Repeated login without changing password should still send user to update password page.
loginPage.open();
loginPage.login("mustchange", "Password1");
passwordUpdatePage.assertCurrent();
// Change password and verify that user can login with new password.
passwordUpdatePage.changePassword("changedpassword", "changedpassword");
appPage.assertCurrent();
UserRepresentation user = testRealm().users().search("mustchange").get(0);
testRealm().users().get(user.getId()).logout();
loginPage.open();
loginPage.login("mustchange", "changedpassword");
appPage.assertCurrent();
}
}
@@ -28,3 +28,13 @@ dn: ou=OtherPeople,dc=keycloak,dc=org
objectclass: top
objectclass: organizationalUnit
ou: People
dn: uid=keycloak-admin,dc=keycloak,dc=org
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: keycloak-admin
cn: keycloak-admin
sn: keycloak-admin
userPassword: secret
@@ -32,13 +32,21 @@ import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.text.StrSubstitutor;
import org.apache.directory.api.ldap.model.entry.DefaultEntry;
import org.apache.directory.api.ldap.model.entry.DefaultModification;
import org.apache.directory.api.ldap.model.entry.ModificationOperation;
import org.apache.directory.api.ldap.model.exception.LdapEntryAlreadyExistsException;
import org.apache.directory.api.ldap.model.exception.LdapException;
import org.apache.directory.api.ldap.model.exception.LdapInvalidDnException;
import org.apache.directory.api.ldap.model.ldif.LdifEntry;
import org.apache.directory.api.ldap.model.ldif.LdifReader;
import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.server.core.api.DirectoryService;
import org.apache.directory.server.core.api.InterceptorEnum;
import org.apache.directory.server.core.api.authn.ppolicy.PasswordPolicyConfiguration;
import org.apache.directory.server.core.api.interceptor.Interceptor;
import org.apache.directory.server.core.api.partition.Partition;
import org.apache.directory.server.core.authn.AuthenticationInterceptor;
import org.apache.directory.server.core.authn.ppolicy.PpolicyConfigContainer;
import org.apache.directory.server.core.factory.DefaultDirectoryServiceFactory;
import org.apache.directory.server.core.factory.JdbmPartitionFactory;
import org.apache.directory.server.core.normalization.NormalizationInterceptor;
@@ -76,6 +84,8 @@ public class LDAPEmbeddedServer {
public static final String PROPERTY_ENABLE_SSL = "enableSSL";
public static final String PROPERTY_ENABLE_STARTTLS = "enableStartTLS";
public static final String PROPERTY_SET_CONFIDENTIALITY_REQUIRED = "setConfidentialityRequired";
public static final String PROPERTY_PPOLICY_ENABLED = "ppolicy.enabled";
public static final String PROPERTY_PPOLICY_MUST_CHANGE = "ppolicy.mustChange";
private static final String DEFAULT_BASE_DN = "dc=keycloak,dc=org";
private static final String DEFAULT_BIND_HOST = "localhost";
@@ -105,6 +115,8 @@ public class LDAPEmbeddedServer {
protected boolean setConfidentialityRequired = false;
protected String keystoreFile;
protected String certPassword;
protected boolean ppolicyEnabled = false;
protected boolean ppolicyMustChange = false;
protected DirectoryService directoryService;
protected LdapServer ldapServer;
@@ -162,6 +174,8 @@ public class LDAPEmbeddedServer {
this.setConfidentialityRequired = Boolean.valueOf(readProperty(PROPERTY_SET_CONFIDENTIALITY_REQUIRED, "false"));
this.keystoreFile = readProperty(PROPERTY_KEYSTORE_FILE, null);
this.certPassword = readProperty(PROPERTY_CERTIFICATE_PASSWORD, null);
this.ppolicyEnabled = Boolean.valueOf(readProperty(PROPERTY_PPOLICY_ENABLED, "false"));
this.ppolicyMustChange = Boolean.valueOf(readProperty(PROPERTY_PPOLICY_MUST_CHANGE, "false"));
}
protected String readProperty(String propertyName, String defaultValue) {
@@ -191,6 +205,11 @@ public class LDAPEmbeddedServer {
log.info("Creating LDAP server..");
this.ldapServer = createLdapServer();
if (this.ppolicyEnabled) {
log.info("Enabling Password Policy");
createDefaultPasswordPolicy();
}
}
@@ -419,4 +438,32 @@ public class LDAPEmbeddedServer {
}
}
protected void createDefaultPasswordPolicy() throws LdapInvalidDnException {
AuthenticationInterceptor authenticationInterceptor = (AuthenticationInterceptor) this.directoryService
.getInterceptor(InterceptorEnum.AUTHENTICATION_INTERCEPTOR.getName());
PasswordPolicyConfiguration policyConfig = new PasswordPolicyConfiguration();
policyConfig.setPwdMustChange(ppolicyMustChange);
PpolicyConfigContainer policyContainer = new PpolicyConfigContainer();
Dn defaultPolicyDn = new Dn( ldapServer.getDirectoryService().getSchemaManager(), "cn=defaultPasswordPolicy" );
policyContainer.addPolicy( defaultPolicyDn, policyConfig );
policyContainer.setDefaultPolicyDn( defaultPolicyDn );
authenticationInterceptor.setPwdPolicies( policyContainer );
}
public void setPwdReset(String userDn, boolean value) {
// pwdReset is a ppolicy operational attribute that can only be modified via the
// embedded server's internal admin session, not through the LDAP protocol.
try {
Dn dn = new Dn(directoryService.getSchemaManager(), userDn);
directoryService.getAdminSession().modify(dn,
new DefaultModification(value ? ModificationOperation.ADD_ATTRIBUTE : ModificationOperation.REMOVE_ATTRIBUTE,
"pwdReset", String.valueOf(value).toUpperCase()));
} catch (LdapException e) {
throw new RuntimeException("Failed to set pwdReset for " + userDn, e);
}
}
}