diff --git a/federation/ldap/src/main/java/org/keycloak/services/managers/LDAPServerCapabilitiesManager.java b/federation/ldap/src/main/java/org/keycloak/services/managers/LDAPServerCapabilitiesManager.java index 87381626196..0ca722aaa57 100755 --- a/federation/ldap/src/main/java/org/keycloak/services/managers/LDAPServerCapabilitiesManager.java +++ b/federation/ldap/src/main/java/org/keycloak/services/managers/LDAPServerCapabilitiesManager.java @@ -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) { diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPConfig.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPConfig.java index 05dd18a6f55..158eb4b3cbc 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPConfig.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPConfig.java @@ -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); } diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java index a11ea432916..1203033069a 100755 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java @@ -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,31 @@ 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 (1) import enabled, so that we can persist required actions and + // (2) edit mode writable, so that user can modify LDAP password. + if (!model.isImportEnabled() || editMode != EditMode.WRITABLE) { + logger.debugf("User '%s' in realm '%s' is forced to change password but UPDATE_PASSWORD cannot be set: import not enabled or edit mode 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 processed = new AtomicReference<>(false); realm.getComponentsStream(model.getId(), LDAPStorageMapper.class.getName()) diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java index a9c5b88f1c2..9c4c2c54d0c 100755 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java @@ -223,6 +223,10 @@ public class LDAPStorageProviderFactory implements UserStorageProviderFactory 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); + } + } + +} diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPContextManager.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPContextManager.java index b927d1d2f71..b8aa2b5278a 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPContextManager.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPContextManager.java @@ -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 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 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 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 getConnectionProperties(LDAPConfig ldapConfig) { - Hashtable 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 copyEnv = new Hashtable<>(env); + Map 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(); diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPOperationManager.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPOperationManager.java index af534cadfae..9e1617cfd9e 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPOperationManager.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPOperationManager.java @@ -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(); } diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControl.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControl.java new file mode 100644 index 00000000000..184242ebbe2 --- /dev/null +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControl.java @@ -0,0 +1,99 @@ +/* + * 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) { + 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]; + } + +} diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControlFactory.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControlFactory.java new file mode 100644 index 00000000000..446d6e41671 --- /dev/null +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControlFactory.java @@ -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; + } + +} diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyPasswordChangeException.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyPasswordChangeException.java new file mode 100644 index 00000000000..ebd88d75b3e --- /dev/null +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyPasswordChangeException.java @@ -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(); + } + +} diff --git a/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControlTest.java b/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControlTest.java new file mode 100644 index 00000000000..3c5722935ee --- /dev/null +++ b/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/store/ldap/control/PasswordPolicyControlTest.java @@ -0,0 +1,50 @@ +/* + * 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() { + PasswordPolicyControl control = new PasswordPolicyControl(new byte[] { 0x30, 0x03, (byte) 0x81, 0x01, 0x02 }); + Assert.assertTrue(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 }); + } + + + } diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 3b035b93272..a696ab12d43 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -2824,6 +2824,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 diff --git a/js/apps/admin-ui/src/user-federation/ldap/LdapSettingsAdvanced.tsx b/js/apps/admin-ui/src/user-federation/ldap/LdapSettingsAdvanced.tsx index d6851123554..3405683f58c 100644 --- a/js/apps/admin-ui/src/user-federation/ldap/LdapSettingsAdvanced.tsx +++ b/js/apps/admin-ui/src/user-federation/ldap/LdapSettingsAdvanced.tsx @@ -122,6 +122,36 @@ export const LdapSettingsAdvanced = ({ > + + } + fieldId="kc-enable-ldap-password-policy" + hasNoPaddingTop + > + ( + field.onChange([`${value}`])} + isChecked={field.value[0] === "true"} + label={t("on")} + labelOff={t("off")} + aria-label={t("enableLdapPasswordPolicy")} + /> + )} + > + + { + 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"); + }); + } + + @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(); + } + +} diff --git a/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/LDAPEmbeddedServer.java b/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/LDAPEmbeddedServer.java index 269c8f21bd2..34a833cb9f2 100644 --- a/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/LDAPEmbeddedServer.java +++ b/util/embedded-ldap/src/main/java/org/keycloak/util/ldap/LDAPEmbeddedServer.java @@ -34,11 +34,17 @@ import org.apache.commons.lang3.text.StrSubstitutor; import org.apache.directory.api.ldap.model.entry.DefaultEntry; 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 +82,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 +113,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 +172,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 +203,11 @@ public class LDAPEmbeddedServer { log.info("Creating LDAP server.."); this.ldapServer = createLdapServer(); + + if (this.ppolicyEnabled) { + log.info("Enabling Password Policy"); + createDefaultPasswordPolicy(); + } } @@ -419,4 +436,19 @@ 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 ); + } + }