diff --git a/core/src/main/java/org/keycloak/representations/idm/LDAPCapabilityRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/LDAPCapabilityRepresentation.java new file mode 100644 index 0000000000..3933bb062c --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/LDAPCapabilityRepresentation.java @@ -0,0 +1,95 @@ +/* + * Copyright 2019 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.representations.idm; + +import java.util.Objects; + +import org.keycloak.common.util.ObjectUtil; + +/** + * Value object to represent an OID (object identifier) as used to describe LDAP schema, extension and features. + * See LDAP OID Reference Guide. + * + * @author Lars Uffmann, 2020-05-13 + * @since 11.0 + */ +public class LDAPCapabilityRepresentation { + + public enum CapabilityType { + CONTROL, + EXTENSION, + FEATURE, + UNKNOWN; + + public static CapabilityType fromRootDseAttributeName(String attributeName) { + switch (attributeName) { + case "supportedExtension": return CapabilityType.EXTENSION; + case "supportedControl": return CapabilityType.CONTROL; + case "supportedFeatures": return CapabilityType.FEATURE; + default: return CapabilityType.UNKNOWN; + } + } + }; + + private Object oid; + + private CapabilityType type; + + public LDAPCapabilityRepresentation() { + } + + public LDAPCapabilityRepresentation(Object oidValue, CapabilityType type) { + this.oid = Objects.requireNonNull(oidValue); + this.type = type; + } + + public String getOid() { + return oid instanceof String ? (String) oid : String.valueOf(oid); + } + + public CapabilityType getType() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + LDAPCapabilityRepresentation ldapOid = (LDAPCapabilityRepresentation) o; + return ObjectUtil.isEqualOrBothNull(oid, ldapOid.oid) && ObjectUtil.isEqualOrBothNull(type, ldapOid.type); + } + + @Override + public int hashCode() { + return oid.hashCode(); + } + + @Override + public String toString() { + return new StringBuilder(LDAPCapabilityRepresentation.class.getSimpleName() + "[ ") + .append("oid=" + oid + ", ") + .append("type=" + type + " ]") + .toString(); + } +} diff --git a/core/src/main/java/org/keycloak/representations/idm/TestLdapConnectionRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/TestLdapConnectionRepresentation.java index 8b84fec92e..fbf75feedb 100644 --- a/core/src/main/java/org/keycloak/representations/idm/TestLdapConnectionRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/TestLdapConnectionRepresentation.java @@ -10,17 +10,24 @@ public class TestLdapConnectionRepresentation { private String connectionTimeout; private String componentId; private String startTls; + private String authType; public TestLdapConnectionRepresentation() { } public TestLdapConnectionRepresentation(String action, String connectionUrl, String bindDn, String bindCredential, String useTruststoreSpi, String connectionTimeout) { + this(action, connectionUrl, bindDn, bindCredential, useTruststoreSpi, connectionTimeout, null, null); + } + + public TestLdapConnectionRepresentation(String action, String connectionUrl, String bindDn, String bindCredential, String useTruststoreSpi, String connectionTimeout, String startTls, String authType) { this.action = action; this.connectionUrl = connectionUrl; this.bindDn = bindDn; this.bindCredential = bindCredential; this.useTruststoreSpi = useTruststoreSpi; this.connectionTimeout = connectionTimeout; + this.startTls = startTls; + this.authType = authType; } public String getAction() { @@ -39,6 +46,14 @@ public class TestLdapConnectionRepresentation { this.connectionUrl = connectionUrl; } + public String getAuthType() { + return authType; + } + + public void setAuthType(String authType) { + this.authType = authType; + } + public String getBindDn() { return bindDn; } 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 8cdce70f58..1044f269da 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 @@ -60,6 +60,11 @@ public class LDAPConfig { } } + public boolean useExtendedPasswordModifyOp() { + String value = config.getFirst(LDAPConstants.USE_PASSWORD_MODIFY_EXTENDED_OP); + return Boolean.parseBoolean(value); + } + public String getUseTruststoreSpi() { return config.getFirst(LDAPConstants.USE_TRUSTSTORE_SPI); } 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 80263d57cd..db4e5d41a6 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 @@ -49,6 +49,8 @@ import org.keycloak.storage.ldap.idm.query.internal.LDAPQueryConditionsBuilder; import org.keycloak.storage.ldap.idm.store.ldap.LDAPIdentityStore; import org.keycloak.storage.ldap.mappers.FullNameLDAPStorageMapper; import org.keycloak.storage.ldap.mappers.FullNameLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.HardcodedLDAPAttributeMapper; +import org.keycloak.storage.ldap.mappers.HardcodedLDAPAttributeMapperFactory; import org.keycloak.storage.ldap.mappers.LDAPConfigDecorator; import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; import org.keycloak.storage.ldap.mappers.LDAPStorageMapperFactory; @@ -106,6 +108,9 @@ public class LDAPStorageProviderFactory implements UserStorageProviderFactory int countQueryResults(RelationshipQuery query); + /** + * Query the LDAP server RootDSE and extract the {@link LDAPCapabilityRepresentation} + * of all supported extensions, controls and features the server announces. The LDAP Wiki + * provides a list of known capabilities. + * + * Will throw a {@link ModelException} on any LDAP error, or when the searchResult is empty. + * + * @return a set of LDAPOid, each representing a server capability (control, extension or feature). + */ + Set queryServerCapabilities(); + // Credentials /** diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPIdentityStore.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPIdentityStore.java index 9c3682e48c..4ce9968a01 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPIdentityStore.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPIdentityStore.java @@ -23,8 +23,10 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.LDAPConstants; import org.keycloak.models.ModelException; import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation.CapabilityType; import org.keycloak.storage.ldap.idm.model.LDAPDn; import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation; import org.keycloak.storage.ldap.idm.query.Condition; import org.keycloak.storage.ldap.idm.query.EscapeStrategy; import org.keycloak.storage.ldap.idm.query.internal.EqualCondition; @@ -306,6 +308,40 @@ public class LDAPIdentityStore implements IdentityStore { return resultCount; } + @Override + public Set queryServerCapabilities() { + Set result = new LinkedHashSet<>(); + try { + List attrs = new ArrayList<>(); + attrs.add("supportedControl"); + attrs.add("supportedExtension"); + attrs.add("supportedFeatures"); + List searchResults = operationManager + .search("", "(objectClass=*)", Collections.unmodifiableCollection(attrs), SearchControls.OBJECT_SCOPE); + if (searchResults.size() != 1) { + throw new ModelException("Could not query root DSE: unexpected result size"); + } + SearchResult rootDse = searchResults.get(0); + Attributes attributes = rootDse.getAttributes(); + for (String attr: attrs) { + Attribute attribute = attributes.get(attr); + if (null != attribute) { + CapabilityType capabilityType = CapabilityType.fromRootDseAttributeName(attr); + NamingEnumeration values = attribute.getAll(); + while (values.hasMoreElements()) { + Object o = values.nextElement(); + LDAPCapabilityRepresentation capability = new LDAPCapabilityRepresentation(o, capabilityType); + logger.info("rootDSE query: " + capability); + result.add(capability); + } + } + } + return result; + } catch (NamingException e) { + throw new ModelException("Failed to query root DSE: " + e.getMessage(), e); + } + } + // *************** CREDENTIALS AND USER SPECIFIC STUFF @Override @@ -329,24 +365,25 @@ public class LDAPIdentityStore implements IdentityStore { if (getConfig().isActiveDirectory()) { updateADPassword(userDN, password, passwordUpdateDecorator); - } else { - ModificationItem[] mods = new ModificationItem[1]; + return; + } - try { + try { + if (config.useExtendedPasswordModifyOp()) { + operationManager.passwordModifyExtended(userDN, password, passwordUpdateDecorator); + } else { + ModificationItem[] mods = new ModificationItem[1]; BasicAttribute mod0 = new BasicAttribute(LDAPConstants.USER_PASSWORD_ATTRIBUTE, password); - mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, mod0); - operationManager.modifyAttributes(userDN, mods, passwordUpdateDecorator); - } catch (ModelException me) { - throw me; - } catch (Exception e) { - throw new ModelException("Error updating password.", e); } + } catch (ModelException me) { + throw me; + } catch (Exception e) { + throw new ModelException("Error updating password.", e); } } - private void updateADPassword(String userDN, String password, LDAPOperationDecorator passwordUpdateDecorator) { try { // Replace the "unicdodePwd" attribute with a new value 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 76ce91ca20..5c61b1880e 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 @@ -25,6 +25,7 @@ import org.keycloak.models.ModelException; import org.keycloak.storage.ldap.LDAPConfig; import org.keycloak.storage.ldap.idm.model.LDAPDn; import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; +import org.keycloak.storage.ldap.idm.store.ldap.extended.PasswordModifyRequest; import org.keycloak.storage.ldap.mappers.LDAPOperationDecorator; import javax.naming.AuthenticationException; @@ -669,6 +670,25 @@ public class LDAPOperationManager { return entryUUID.toString(); } + /** + * Execute the LDAP Password Modify Extended Operation to update the password for the given DN. + * + * @param dn distinguished name of the entry. + * @param password the new password. + * @param decorator A decorator to apply to the ldap operation. + */ + + public void passwordModifyExtended(String dn, String password, LDAPOperationDecorator decorator) { + try { + execute(context -> { + PasswordModifyRequest modifyRequest = new PasswordModifyRequest(dn, null, password); + return context.extendedOperation(modifyRequest); + }, decorator); + } catch (NamingException e) { + throw new ModelException("Could not execute the password modify extended operation for DN [" + dn + "]", e); + } + } + private R execute(LdapOperation operation) throws NamingException { return execute(operation, null); } diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/extended/PasswordModifyRequest.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/extended/PasswordModifyRequest.java new file mode 100644 index 0000000000..cb73ddf843 --- /dev/null +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/extended/PasswordModifyRequest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * 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 + * + * https://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.extended; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.naming.ldap.ExtendedRequest; +import javax.naming.ldap.ExtendedResponse; + +/** + * An implementation of the + * + * LDAP Password Modify Extended Operation + * + * client request. + *

+ * Can be directed at any LDAP server that supports the Password Modify Extended Operation. + * + * @author Josh Cummings + * @since 4.2.9 + */ +public final class PasswordModifyRequest implements ExtendedRequest { + + public static final String PASSWORD_MODIFY_OID = "1.3.6.1.4.1.4203.1.11.1"; + + private static final byte SEQUENCE_TYPE = 48; + private static final byte USER_IDENTITY_OCTET_TYPE = -128; + private static final byte OLD_PASSWORD_OCTET_TYPE = -127; + private static final byte NEW_PASSWORD_OCTET_TYPE = -126; + + private final ByteArrayOutputStream value = new ByteArrayOutputStream(); + + public PasswordModifyRequest(String userIdentity, String oldPassword, String newPassword) { + ByteArrayOutputStream elements = new ByteArrayOutputStream(); + + if (userIdentity != null) { + berEncode(USER_IDENTITY_OCTET_TYPE, userIdentity.getBytes(), elements); + } + + if (oldPassword != null) { + berEncode(OLD_PASSWORD_OCTET_TYPE, oldPassword.getBytes(), elements); + } + + if (newPassword != null) { + berEncode(NEW_PASSWORD_OCTET_TYPE, newPassword.getBytes(), elements); + } + + berEncode(SEQUENCE_TYPE, elements.toByteArray(), this.value); + } + + @Override + public String getID() { + return PASSWORD_MODIFY_OID; + } + + @Override + public byte[] getEncodedValue() { + return this.value.toByteArray(); + } + + @Override + public ExtendedResponse createExtendedResponse(String id, byte[] berValue, int offset, int length) { + return null; + } + + /** + * Only minimal support for + * + * BER encoding + * ; just what is necessary for the Password Modify request. + */ + private void berEncode(byte type, byte[] src, ByteArrayOutputStream dest) { + int length = src.length; + + dest.write(type); + + if (length < 128) { + dest.write(length); + } else if ((length & 0x0000_00FF) == length) { + dest.write((byte) 0x81); + dest.write((byte) (length & 0xFF)); + } else if ((length & 0x0000_FFFF) == length) { + dest.write((byte) 0x82); + dest.write((byte) ((length >> 8) & 0xFF)); + dest.write((byte) (length & 0xFF)); + } else if ((length & 0x00FF_FFFF) == length) { + dest.write((byte) 0x83); + dest.write((byte) ((length >> 16) & 0xFF)); + dest.write((byte) ((length >> 8) & 0xFF)); + dest.write((byte) (length & 0xFF)); + } else { + dest.write((byte) 0x84); + dest.write((byte) ((length >> 24) & 0xFF)); + dest.write((byte) ((length >> 16) & 0xFF)); + dest.write((byte) ((length >> 8) & 0xFF)); + dest.write((byte) (length & 0xFF)); + } + + try { + dest.write(src); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to BER encode provided value of type: " + type); + } + } +} diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPAttributeMapper.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPAttributeMapper.java index 035c623713..220f779665 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPAttributeMapper.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPAttributeMapper.java @@ -41,6 +41,13 @@ public class HardcodedLDAPAttributeMapper extends AbstractLDAPStorageMapper { public static final String LDAP_ATTRIBUTE_VALUE = "ldap.attribute.value"; + private static final String RANDOM = "RANDOM"; + + /** + * When this is configured as LDAP_ATTRIBUTE_VALUE, the mapper will use randomly generated value + */ + public static final String RANDOM_ATTRIBUTE_VALUE = "${" + RANDOM + "}"; + public static Pattern substitution = Pattern.compile("\\$\\{([^}]+)\\}"); public HardcodedLDAPAttributeMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider) { @@ -65,7 +72,7 @@ public class HardcodedLDAPAttributeMapper extends AbstractLDAPStorageMapper { while (m.find()) { String token = m.group(1); - if (token.equals("RANDOM")) { + if (token.equals(RANDOM)) { String randomVal = getRandomValue(); m.appendReplacement(sb, randomVal); } else { diff --git a/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPCapabilityTest.java b/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPCapabilityTest.java new file mode 100644 index 0000000000..3843c7820f --- /dev/null +++ b/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPCapabilityTest.java @@ -0,0 +1,56 @@ +package org.keycloak.storage.ldap.idm.model; + +import static junit.framework.TestCase.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.LinkedHashSet; +import java.util.Set; +import org.junit.Test; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation.CapabilityType; +import org.keycloak.storage.ldap.idm.store.ldap.extended.PasswordModifyRequest; + +public class LDAPCapabilityTest { + + @Test + public void testEquals() { + LDAPCapabilityRepresentation oid1 = new LDAPCapabilityRepresentation(PasswordModifyRequest.PASSWORD_MODIFY_OID, CapabilityType.CONTROL); + LDAPCapabilityRepresentation oid2 = new LDAPCapabilityRepresentation(PasswordModifyRequest.PASSWORD_MODIFY_OID, CapabilityType.EXTENSION); + LDAPCapabilityRepresentation oid3 = new LDAPCapabilityRepresentation(PasswordModifyRequest.PASSWORD_MODIFY_OID, CapabilityType.EXTENSION); + assertFalse(oid1.equals(oid2)); + assertTrue(oid2.equals(oid3)); + System.out.println(oid1); + } + + @Test + public void testContains() { + LDAPCapabilityRepresentation oid1 = new LDAPCapabilityRepresentation(PasswordModifyRequest.PASSWORD_MODIFY_OID, CapabilityType.EXTENSION); + LDAPCapabilityRepresentation oidx = new LDAPCapabilityRepresentation(PasswordModifyRequest.PASSWORD_MODIFY_OID, CapabilityType.EXTENSION); + LDAPCapabilityRepresentation oid2 = new LDAPCapabilityRepresentation("13.2.3.11.22", CapabilityType.CONTROL); + LDAPCapabilityRepresentation oid3 = new LDAPCapabilityRepresentation("14.2.3.42.22", CapabilityType.FEATURE); + Set ids = new LinkedHashSet<>(); + ids.add(oid1); + ids.add(oidx); + ids.add(oid2); + ids.add(oid3); + assertTrue(ids.contains(oid1)); + assertTrue(ids.contains(oidx)); + assertEquals(3, ids.size()); + } + + @Test + public void testCapabilityTypeFromAttributeName() { + CapabilityType extension = CapabilityType.fromRootDseAttributeName("supportedExtension"); + assertEquals(CapabilityType.EXTENSION, extension); + + CapabilityType control = CapabilityType.fromRootDseAttributeName("supportedControl"); + assertEquals(CapabilityType.CONTROL, control); + + CapabilityType feature = CapabilityType.fromRootDseAttributeName("supportedFeatures"); + assertEquals(CapabilityType.FEATURE, feature); + + CapabilityType unknown = CapabilityType.fromRootDseAttributeName("foo"); + assertEquals(CapabilityType.UNKNOWN, unknown); + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/RealmResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/RealmResource.java index 41b6a49718..635f0f7698 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/RealmResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/RealmResource.java @@ -24,6 +24,7 @@ import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation; import org.keycloak.representations.idm.PartialImportRepresentation; import org.keycloak.representations.idm.RealmEventsConfigRepresentation; import org.keycloak.representations.idm.RealmRepresentation; @@ -222,6 +223,13 @@ public interface RealmResource { @NoCache Response testLDAPConnection(TestLdapConnectionRepresentation config); + @POST + @Path("ldap-server-capabilities") + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON) + List ldapServerCapabilities(TestLdapConnectionRepresentation config); + @Path("testSMTPConnection") @POST @NoCache diff --git a/server-spi-private/src/main/java/org/keycloak/models/LDAPConstants.java b/server-spi-private/src/main/java/org/keycloak/models/LDAPConstants.java index f001ba9090..d2fc898b87 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/LDAPConstants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/LDAPConstants.java @@ -35,6 +35,9 @@ public class LDAPConstants { public static final String VENDOR_TIVOLI = "tivoli"; public static final String VENDOR_NOVELL_EDIRECTORY="edirectory" ; + // Could be discovered by rootDse supportedExtension: 1.3.6.1.4.1.4203.1.11.1 + public static final String USE_PASSWORD_MODIFY_EXTENDED_OP = "usePasswordModifyExtendedOp"; + public static final String USERNAME_LDAP_ATTRIBUTE = "usernameLDAPAttribute"; public static final String RDN_LDAP_ATTRIBUTE = "rdnLDAPAttribute"; public static final String UUID_LDAP_ATTRIBUTE = "uuidLDAPAttribute"; @@ -66,6 +69,7 @@ public class LDAPConstants { public static final String CONNECTION_POOLING_TIMEOUT = "connectionPoolingTimeout"; public static final String CONNECTION_TIMEOUT = "connectionTimeout"; public static final String READ_TIMEOUT = "readTimeout"; + // Could be discovered by rootDse supportedControl: 1.2.840.113556.1.4.319 public static final String PAGINATION = "pagination"; public static final String EDIT_MODE = "editMode"; @@ -84,6 +88,7 @@ public class LDAPConstants { // Custom user search filter public static final String CUSTOM_USER_SEARCH_FILTER = "customUserSearchFilter"; + // Could be discovered by rootDse supportedExtension: 1.3.6.1.4.1.1466.20037 public static final String START_TLS = "startTls"; // Custom attributes on UserModel, which is mapped to LDAP diff --git a/services/src/main/java/org/keycloak/services/managers/LDAPConnectionTestManager.java b/services/src/main/java/org/keycloak/services/managers/LDAPConnectionTestManager.java deleted file mode 100755 index e378a52c91..0000000000 --- a/services/src/main/java/org/keycloak/services/managers/LDAPConnectionTestManager.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2016 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.services.managers; - -import org.jboss.logging.Logger; -import org.keycloak.common.util.MultivaluedHashMap; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.LDAPConstants; -import org.keycloak.services.ServicesLogger; -import org.keycloak.storage.ldap.LDAPConfig; -import org.keycloak.storage.ldap.idm.store.ldap.LDAPContextManager; - -/** - * @author Marek Posolda - */ -public class LDAPConnectionTestManager { - - private static final Logger logger = Logger.getLogger(LDAPConnectionTestManager.class); - - public static final String TEST_CONNECTION = "testConnection"; - public static final String TEST_AUTHENTICATION = "testAuthentication"; - - public static boolean testLDAP(KeycloakSession session, String action, String connectionUrl, String bindDn, - String bindCredential, String useTruststoreSpi, String connectionTimeout, String tls) { - if (!TEST_CONNECTION.equals(action) && !TEST_AUTHENTICATION.equals(action)) { - ServicesLogger.LOGGER.unknownAction(action); - return false; - } - - - // Prepare MultivaluedHashMap so that it is usable in LDAPContext class - MultivaluedHashMap ldapConfig = new MultivaluedHashMap<>(); - - if (connectionUrl == null) { - logger.errorf("Unknown connection URL"); - return false; - } - ldapConfig.putSingle(LDAPConstants.CONNECTION_URL, connectionUrl); - ldapConfig.putSingle(LDAPConstants.USE_TRUSTSTORE_SPI, useTruststoreSpi); - ldapConfig.putSingle(LDAPConstants.CONNECTION_TIMEOUT, connectionTimeout); - ldapConfig.putSingle(LDAPConstants.START_TLS, tls); - - if (TEST_AUTHENTICATION.equals(action)) { - // If AUTHENTICATION action is executed add also dn and credentials to configuration - // LDAPContextManager is responsible for correct order of addition of credentials to context in case - // tls is true - - if (bindDn == null) { - logger.error("Unknown bind DN"); - return false; - } - - ldapConfig.putSingle(LDAPConstants.AUTH_TYPE, LDAPConstants.AUTH_TYPE_SIMPLE); - ldapConfig.putSingle(LDAPConstants.BIND_DN, bindDn); - ldapConfig.putSingle(LDAPConstants.BIND_CREDENTIAL, bindCredential); - } else { - ldapConfig.putSingle(LDAPConstants.AUTH_TYPE, LDAPConstants.AUTH_TYPE_NONE); - } - - // Create ldapContextManager in try-with-resource so that ldapContext/tlsResponse/VaultSecret is closed/removed when it is not needed anymore - try (LDAPContextManager ldapContextManager = LDAPContextManager.create(session, new LDAPConfig(ldapConfig))) { - ldapContextManager.getLdapContext(); - - // Connection was successful, no exception was raised returning true - return true; - } catch (Exception ne) { - String errorMessage = (TEST_AUTHENTICATION.equals(action)) ? "Error when authenticating to LDAP: " : "Error when connecting to LDAP: "; - ServicesLogger.LOGGER.errorAuthenticating(ne, errorMessage + ne.getMessage()); - return false; - } - } -} diff --git a/services/src/main/java/org/keycloak/services/managers/LDAPServerCapabilitiesManager.java b/services/src/main/java/org/keycloak/services/managers/LDAPServerCapabilitiesManager.java new file mode 100755 index 0000000000..41f51bd497 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/managers/LDAPServerCapabilitiesManager.java @@ -0,0 +1,110 @@ +/* + * Copyright 2016 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.services.managers; + +import java.util.Collections; +import java.util.Set; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.representations.idm.ComponentRepresentation; +import org.keycloak.representations.idm.TestLdapConnectionRepresentation; +import org.keycloak.services.ServicesLogger; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation; +import org.keycloak.storage.ldap.idm.store.ldap.LDAPContextManager; +import org.keycloak.storage.ldap.idm.store.ldap.LDAPIdentityStore; + +/** + * @author Marek Posolda + */ +public class LDAPServerCapabilitiesManager { + + private static final Logger logger = Logger.getLogger(LDAPServerCapabilitiesManager.class); + + public static final String TEST_CONNECTION = "testConnection"; + public static final String TEST_AUTHENTICATION = "testAuthentication"; + public static final String QUERY_SERVER_CAPABILITIES = "queryServerCapabilities"; + + public static LDAPConfig buildLDAPConfig(TestLdapConnectionRepresentation config, RealmModel realm) { + String bindCredential = config.getBindCredential(); + if (config.getComponentId() != null && ComponentRepresentation.SECRET_VALUE.equals(bindCredential)) { + bindCredential = realm.getComponent(config.getComponentId()).getConfig().getFirst(LDAPConstants.BIND_CREDENTIAL); + } + MultivaluedHashMap configMap = new MultivaluedHashMap<>(); + configMap.putSingle(LDAPConstants.AUTH_TYPE, config.getAuthType()); + configMap.putSingle(LDAPConstants.BIND_DN, config.getBindDn()); + configMap.putSingle(LDAPConstants.BIND_CREDENTIAL, bindCredential); + configMap.add(LDAPConstants.CONNECTION_URL, config.getConnectionUrl()); + configMap.add(LDAPConstants.USE_TRUSTSTORE_SPI, config.getUseTruststoreSpi()); + configMap.putSingle(LDAPConstants.CONNECTION_TIMEOUT, config.getConnectionTimeout()); + configMap.add(LDAPConstants.START_TLS, config.getStartTls()); + return new LDAPConfig(configMap); + } + + public static Set queryServerCapabilities(TestLdapConnectionRepresentation config, KeycloakSession session, + RealmModel realm) { + + if (! QUERY_SERVER_CAPABILITIES.equals(config.getAction())) { + ServicesLogger.LOGGER.unknownAction(config.getAction()); + return Collections.emptySet(); + } + + LDAPConfig ldapConfig = buildLDAPConfig(config, realm); + return new LDAPIdentityStore(session, ldapConfig).queryServerCapabilities(); + } + + public static boolean testLDAP(TestLdapConnectionRepresentation config, KeycloakSession session, RealmModel realm) { + + if (!TEST_CONNECTION.equals(config.getAction()) && !TEST_AUTHENTICATION.equals(config.getAction())) { + ServicesLogger.LOGGER.unknownAction(config.getAction()); + return false; + } + + if (TEST_AUTHENTICATION.equals(config.getAction())) { + // If AUTHENTICATION action is executed add also dn and credentials to configuration + // LDAPContextManager is responsible for correct order of addition of credentials to context in case + // tls is true + if (config.getBindDn() == null || config.getBindDn().isEmpty()) { + logger.error("Unknown bind DN"); + return false; + } + } else { + // only test the connection. + config.setAuthType(LDAPConstants.AUTH_TYPE_NONE); + } + + LDAPConfig ldapConfig = buildLDAPConfig(config, realm); + + // Create ldapContextManager in try-with-resource so that ldapContext/tlsResponse/VaultSecret is closed/removed when it + // is not needed anymore + try (LDAPContextManager ldapContextManager = LDAPContextManager.create(session, ldapConfig)) { + ldapContextManager.getLdapContext(); + + // Connection was successful, no exception was raised returning true + return true; + } catch (Exception ne) { + String errorMessage = (TEST_AUTHENTICATION.equals(config.getAction())) ? "Error when authenticating to LDAP: " + : "Error when connecting to LDAP: "; + ServicesLogger.LOGGER.errorAuthenticating(ne, errorMessage + ne.getMessage()); + return false; + } + } +} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java index 70e527ca84..274b59ac94 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java @@ -16,21 +16,49 @@ */ package org.keycloak.services.resources.admin; -import com.fasterxml.jackson.core.type.TypeReference; +import static org.keycloak.models.utils.StripSecretsUtils.stripForExport; +import static org.keycloak.util.JsonSerialization.readValue; + +import java.security.cert.X509Certificate; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.ws.rs.BadRequestException; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; + import org.jboss.logging.Logger; import org.jboss.resteasy.annotations.cache.NoCache; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.NotFoundException; import org.jboss.resteasy.spi.ResteasyProviderFactory; import org.keycloak.Config; import org.keycloak.KeyPairVerifier; import org.keycloak.authentication.CredentialRegistrator; -import org.keycloak.authentication.RequiredActionFactory; import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.common.ClientConnection; import org.keycloak.common.VerificationException; import org.keycloak.common.util.PemUtils; -import org.keycloak.credential.CredentialProvider; import org.keycloak.email.EmailTemplateProvider; import org.keycloak.events.Event; import org.keycloak.events.EventQuery; @@ -45,7 +73,18 @@ import org.keycloak.exportimport.ClientDescriptionConverterFactory; import org.keycloak.exportimport.util.ExportOptions; import org.keycloak.exportimport.util.ExportUtils; import org.keycloak.keys.PublicKeyStorageProvider; -import org.keycloak.models.*; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.Constants; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionProviderModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; import org.keycloak.models.cache.CacheRealmProvider; import org.keycloak.models.cache.UserCache; import org.keycloak.models.utils.KeycloakModelUtils; @@ -69,7 +108,7 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.TestLdapConnectionRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.managers.AuthenticationManager; -import org.keycloak.services.managers.LDAPConnectionTestManager; +import org.keycloak.services.managers.LDAPServerCapabilitiesManager; import org.keycloak.services.managers.RealmManager; import org.keycloak.services.managers.ResourceAdminManager; import org.keycloak.services.managers.UserStorageSyncManager; @@ -77,37 +116,11 @@ import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluato import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement; import org.keycloak.services.resources.admin.permissions.AdminPermissions; import org.keycloak.storage.UserStorageProviderModel; - -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.FormParam; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import java.security.cert.X509Certificate; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import static org.keycloak.models.utils.StripSecretsUtils.stripForExport; -import static org.keycloak.util.JsonSerialization.readValue; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation; import org.keycloak.utils.ReservedCharValidator; +import com.fasterxml.jackson.core.type.TypeReference; + /** * Base resource class for the admin REST api of one realm * @@ -938,11 +951,9 @@ public class RealmAdminResource { @FormParam("componentId") String componentId, @FormParam("startTls") String startTls) { auth.realm().requireManageRealm(); - if (componentId != null && bindCredential.equals(ComponentRepresentation.SECRET_VALUE)) { - bindCredential = realm.getComponent(componentId).getConfig().getFirst(LDAPConstants.BIND_CREDENTIAL); - } - - boolean result = LDAPConnectionTestManager.testLDAP(session, action, connectionUrl, bindDn, bindCredential, useTruststoreSpi, connectionTimeout, startTls); + TestLdapConnectionRepresentation config = new TestLdapConnectionRepresentation(action, connectionUrl, bindDn, bindCredential, useTruststoreSpi, connectionTimeout, startTls, LDAPConstants.AUTH_TYPE_SIMPLE); + config.setComponentId(componentId); + boolean result = LDAPServerCapabilitiesManager.testLDAP(config, session, realm); return result ? Response.noContent().build() : ErrorResponse.error("LDAP test error", Response.Status.BAD_REQUEST); } @@ -955,15 +966,28 @@ public class RealmAdminResource { @NoCache @Consumes(MediaType.APPLICATION_JSON) public Response testLDAPConnection(TestLdapConnectionRepresentation config) { - return testLDAPConnection( - config.getAction(), - config.getConnectionUrl(), - config.getBindDn(), - config.getBindCredential(), - config.getUseTruststoreSpi(), - config.getConnectionTimeout(), - config.getComponentId(), - config.getStartTls()); + boolean result = LDAPServerCapabilitiesManager.testLDAP(config, session, realm); + return result ? Response.noContent().build() : ErrorResponse.error("LDAP test error", Response.Status.BAD_REQUEST); + } + + /** + * Get LDAP supported extensions. + * @param config LDAP configuration + * @return + */ + @POST + @Path("ldap-server-capabilities") + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON) + public Response ldapServerCapabilities(TestLdapConnectionRepresentation config) { + auth.realm().requireManageRealm(); + try { + Set ldapCapabilities = LDAPServerCapabilitiesManager.queryServerCapabilities(config, session, realm); + return Response.ok().entity(ldapCapabilities).build(); + } catch (Exception e) { + return ErrorResponse.error("ldapServerCapabilities error", Status.BAD_REQUEST); + } } /** diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index 8fff8eaacd..4faf7a671f 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -616,6 +616,7 @@ public class UserResource { } catch (ReadOnlyException mre) { throw new BadRequestException("Can't reset password as account is read only"); } catch (ModelException e) { + logger.warn("Could not update user password.", e); Properties messages = AdminRoot.getMessages(session, realm, auth.adminAuth().getToken().getLocale()); throw new ErrorResponseException(e.getMessage(), MessageFormat.format(messages.getProperty(e.getMessage(), e.getMessage()), e.getParameters()), Status.BAD_REQUEST); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserFederationLdapConnectionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserFederationLdapConnectionTest.java index 836c0014f2..f3c8e44098 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserFederationLdapConnectionTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserFederationLdapConnectionTest.java @@ -17,14 +17,21 @@ package org.keycloak.testsuite.admin; +import java.util.List; + +import org.hamcrest.Matchers; import org.junit.ClassRule; import org.junit.Test; +import org.keycloak.models.LDAPConstants; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation; import org.keycloak.representations.idm.TestLdapConnectionRepresentation; -import org.keycloak.services.managers.LDAPConnectionTestManager; +import org.keycloak.services.managers.LDAPServerCapabilitiesManager; +import org.keycloak.storage.ldap.idm.store.ldap.extended.PasswordModifyRequest; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.arquillian.annotation.EnableVault; import org.keycloak.testsuite.util.LDAPRule; +import javax.ws.rs.BadRequestException; import javax.ws.rs.core.Response; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; @@ -46,30 +53,30 @@ public class UserFederationLdapConnectionTest extends AbstractAdminTest { assertStatus(response, 400); // Bad host - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_CONNECTION, "ldap://localhostt:10389", "foo", "bar", "false", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_CONNECTION, "ldap://localhostt:10389", "foo", "bar", "false", null)); assertStatus(response, 400); // Connection success - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_CONNECTION, "ldap://localhost:10389", "foo", "bar", "false", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_CONNECTION, "ldap://localhost:10389", "foo", "bar", "false", null, "false", LDAPConstants.AUTH_TYPE_NONE)); assertStatus(response, 204); // Bad authentication - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "foo", "bar", "false", "10000")); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "foo", "bar", "false", "10000")); assertStatus(response, 400); // Authentication success - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "uid=admin,ou=system", "secret", "false", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "uid=admin,ou=system", "secret", "false", null)); assertStatus(response, 204); // Authentication success with bindCredential from Vault - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "uid=admin,ou=system", "${vault.ldap_bindCredential}", "false", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "uid=admin,ou=system", "${vault.ldap_bindCredential}", "false", null)); assertStatus(response, 204); - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "uid=admin,ou=system", "${vault.ldap_bindCredential}", "false", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "uid=admin,ou=system", "${vault.ldap_bindCredential}", "false", null)); assertStatus(response, 204); // Deprecated form based - response = realm.testLDAPConnection(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "uid=admin,ou=system", "${vault.ldap_bindCredential}", "false", null); + response = realm.testLDAPConnection(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldap://localhost:10389", "uid=admin,ou=system", "${vault.ldap_bindCredential}", "false", null); assertStatus(response, 204); } @@ -77,26 +84,50 @@ public class UserFederationLdapConnectionTest extends AbstractAdminTest { @Test public void testLdapConnectionsSsl() { - Response response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_CONNECTION, "ldaps://localhost:10636", "foo", "bar", "false", null)); + Response response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_CONNECTION, "ldaps://localhost:10636", "foo", "bar", "false", null, null, LDAPConstants.AUTH_TYPE_NONE)); assertStatus(response, 204); - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_CONNECTION, "ldaps://localhostt:10636", "foo", "bar", "false", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_CONNECTION, "ldaps://localhostt:10636", "foo", "bar", "false", null)); assertStatus(response, 400); - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldaps://localhost:10636", "foo", "bar", "false", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldaps://localhost:10636", "foo", "bar", "false", null)); assertStatus(response, 400); - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldaps://localhost:10636", "uid=admin,ou=system", "secret", "true", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldaps://localhost:10636", "uid=admin,ou=system", "secret", "true", null)); assertStatus(response, 204); - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldaps://localhost:10636", "uid=admin,ou=system", "secret", "true", "10000")); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldaps://localhost:10636", "uid=admin,ou=system", "secret", "true", "10000")); assertStatus(response, 204); // Authentication success with bindCredential from Vault - response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPConnectionTestManager.TEST_AUTHENTICATION, "ldaps://localhost:10636", "uid=admin,ou=system", "${vault.ldap_bindCredential}", "true", null)); + response = realm.testLDAPConnection(new TestLdapConnectionRepresentation(LDAPServerCapabilitiesManager.TEST_AUTHENTICATION, "ldaps://localhost:10636", "uid=admin,ou=system", "${vault.ldap_bindCredential}", "true", null)); assertStatus(response, 204); } + @Test + public void testLdapCapabilities() { + + // Query the rootDSE success + TestLdapConnectionRepresentation config = new TestLdapConnectionRepresentation( + LDAPServerCapabilitiesManager.QUERY_SERVER_CAPABILITIES, "ldap://localhost:10389", "uid=admin,ou=system", "secret", + "false", null, "false", LDAPConstants.AUTH_TYPE_SIMPLE); + + List ldapCapabilities = realm.ldapServerCapabilities(config); + Assert.assertThat(ldapCapabilities, Matchers.hasItem(new LDAPCapabilityRepresentation(PasswordModifyRequest.PASSWORD_MODIFY_OID, LDAPCapabilityRepresentation.CapabilityType.EXTENSION))); + + // Query the rootDSE failure + try { + config = new TestLdapConnectionRepresentation( + LDAPServerCapabilitiesManager.QUERY_SERVER_CAPABILITIES, "ldap://localhost:10389", "foo", "bar", + "false", null, "false", LDAPConstants.AUTH_TYPE_SIMPLE); + realm.ldapServerCapabilities(config); + + Assert.fail("It wasn't expected to successfully sent the request for query capabilities"); + } catch (BadRequestException bre) { + // Expected + } + } + private void assertStatus(Response response, int status) { Assert.assertEquals(status, response.getStatus()); response.close(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPPasswordModifyExtensionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPPasswordModifyExtensionTest.java new file mode 100644 index 0000000000..94f17002a0 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPPasswordModifyExtensionTest.java @@ -0,0 +1,146 @@ +/* + * Copyright 2019 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.junit.Assert; +import org.junit.ClassRule; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.UserStorageProviderModel; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.mappers.HardcodedLDAPAttributeMapper; +import org.keycloak.storage.ldap.mappers.HardcodedLDAPAttributeMapperFactory; +import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.util.LDAPRule; +import org.keycloak.testsuite.util.LDAPTestConfiguration; +import org.keycloak.testsuite.util.LDAPTestUtils; + +/** + * Test for the LDAPv3 Password modify extension (https://tools.ietf.org/html/rfc3062) + * + * @author Marek Posolda + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class LDAPPasswordModifyExtensionTest extends AbstractLDAPTest { + + // Run this test for embedded ApacheDS + @ClassRule + public static 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(); + + // Enable Password Modify extension + UserStorageProviderModel model = ctx.getLdapModel(); + model.put(LDAPConstants.USE_PASSWORD_MODIFY_EXTENDED_OP, true); + appRealm.updateComponent(model); + + ComponentModel randomLDAPPasswordMapper = KeycloakModelUtils.createComponentModel("random initial password", model.getId(), HardcodedLDAPAttributeMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + HardcodedLDAPAttributeMapper.LDAP_ATTRIBUTE_NAME, LDAPConstants.USER_PASSWORD_ATTRIBUTE, + HardcodedLDAPAttributeMapper.LDAP_ATTRIBUTE_VALUE, HardcodedLDAPAttributeMapper.RANDOM_ATTRIBUTE_VALUE); + appRealm.addComponentModel(randomLDAPPasswordMapper); + }); + + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + RealmModel appRealm = ctx.getRealm(); + // Delete all LDAP users and add some new for testing + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ctx.getLdapModel()); + LDAPTestUtils.removeAllLDAPUsers(ldapFedProvider, appRealm); + + LDAPObject john = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "johnkeycloak", "John", "Doe", "john@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, john, "Password1"); + + appRealm.getClientByClientId("test-app").setDirectAccessGrantsEnabled(true); + }); + } + + @Test + public void ldapPasswordChangeWithAccountConsole() throws Exception { + changePasswordPage.open(); + loginPage.login("johnkeycloak", "Password1"); + changePasswordPage.changePassword("Password1", "New-password1", "New-password1"); + + Assert.assertEquals("Your password has been updated.", profilePage.getSuccess()); + + changePasswordPage.logout(); + + loginPage.open(); + loginPage.login("johnkeycloak", "Bad-password1"); + Assert.assertEquals("Invalid username or password.", loginPage.getError()); + + loginPage.open(); + loginPage.login("johnkeycloak", "New-password1"); + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + // Change password back to previous value + changePasswordPage.open(); + changePasswordPage.changePassword("New-password1", "Password1", "Password1"); + Assert.assertEquals("Your password has been updated.", profilePage.getSuccess()); + } + + @Test + public void registerUserLdapSuccess() { + loginPage.open(); + loginPage.clickRegister(); + registerPage.assertCurrent(); + + registerPage.register("firstName", "lastName", "email2@check.cz", "registerUserSuccess2", "Password1", "Password1"); + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + UserRepresentation user = ApiUtil.findUserByUsername(testRealm(),"registerUserSuccess2"); + Assert.assertNotNull(user); + assertFederatedUserLink(user); + Assert.assertEquals("registerusersuccess2", user.getUsername()); + Assert.assertEquals("firstName", user.getFirstName()); + Assert.assertEquals("lastName", user.getLastName()); + Assert.assertTrue(user.isEnabled()); + } + + + protected void assertFederatedUserLink(UserRepresentation user) { + Assert.assertTrue(StorageId.isLocalStorage(user.getId())); + Assert.assertNotNull(user.getFederationLink()); + Assert.assertEquals(user.getFederationLink(), ldapModelId); + } +} diff --git a/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/federation/LdapUserProviderForm.java b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/federation/LdapUserProviderForm.java index 1c00ea1dec..0805a9f9f7 100644 --- a/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/federation/LdapUserProviderForm.java +++ b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/federation/LdapUserProviderForm.java @@ -60,6 +60,9 @@ public class LdapUserProviderForm extends Form { @FindBy(id = "searchScope") private Select searchScopeSelect; + @FindBy(id = "kerberosIntegrationHeader") + private WebElement kerberosIntegrationHeader; + @FindBy(id = "kerberosRealm") private WebElement kerberosRealmInput; @@ -123,7 +126,7 @@ public class LdapUserProviderForm extends Form { @FindBy(xpath = ".//div[contains(@class,'onoffswitch') and ./input[@id='changedSyncEnabled']]") private OnOffSwitch periodicChangedUsersSync; - @FindByJQuery("a:contains('Connection Pooling Settings')") + @FindBy(id = "connectionPoolSettingsHeader") private WebElement connectionPoolingSettingsButton; @FindBy(id = "connectionPoolingAuthentication") @@ -191,6 +194,16 @@ public class LdapUserProviderForm extends Form { UIUtils.setTextInputValue(customUserSearchFilterInput, customUserSearchFilter); } + public void uncollapseKerberosIntegrationHeader() { + if (UIUtils.isElementVisible(kerberosRealmInput)) { + // Already collapsed + return; + } + + kerberosIntegrationHeader.click(); + waitUntilElement(By.id("kerberosRealm")).is().present(); + } + public void setKerberosRealmInput(String kerberosRealm) { UIUtils.setTextInputValue(kerberosRealmInput, kerberosRealm); } diff --git a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/federation/LdapUserFederationTest.java b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/federation/LdapUserFederationTest.java index d65829ae1b..37b36c021f 100644 --- a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/federation/LdapUserFederationTest.java +++ b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/federation/LdapUserFederationTest.java @@ -45,6 +45,7 @@ public class LdapUserFederationTest extends AbstractConsoleTest { createLdapUserProvider.form().setLdapBindCredentialInput("secret"); // createLdapUserProvider.form().setAccountAfterPasswordUpdateEnabled(false); // enable kerberos + createLdapUserProvider.form().uncollapseKerberosIntegrationHeader(); createLdapUserProvider.form().setAllowKerberosAuthEnabled(true); createLdapUserProvider.form().setKerberosRealmInput("KEYCLOAK.ORG"); createLdapUserProvider.form().setServerPrincipalInput("HTTP/localhost@KEYCLOAK.ORG"); diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index a1ee33bd90..f7d05075cb 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -945,6 +945,8 @@ import-enabled=Import Users ldap.import-enabled.tooltip=If true, LDAP users will be imported into Keycloak DB and synced by the configured sync policies. vendor=Vendor ldap.vendor.tooltip=LDAP vendor (provider) +enable-usePasswordModifyExtendedOp=Enable the LDAPv3 Password Modify Extended Operation +ldap.usePasswordModifyExtendedOp.tooltip=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. username-ldap-attribute=Username LDAP attribute ldap-attribute-name-for-username=LDAP attribute name for username username-ldap-attribute.tooltip=Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server vendors it can be 'uid'. For Active directory it can be 'sAMAccountName' or 'cn'. The attribute should be filled for all LDAP user records you want to import from LDAP to Keycloak. @@ -1623,3 +1625,8 @@ pkce-code-challenge-method=Proof Key for Code Exchange Code Challenge Method pkce-code-challenge-method.tooltip=Choose which code challenge method for PKCE is used. If not specified, keycloak does not applies PKCE to a client unless the client sends an authorization request with appropriate code challenge and code exchange method. key-not-allowed-here=Key '{{character}}' is not allowed here. + +# KEYCLOAK-10927 Implement LDAPv3 Password Modify Extended Operation +advanced-ldap-settings=Advanced Settings +ldap-query-supported-extensions=Query Supported Extensions +ldap-query-supported-extensions.tooltip=This will query LDAP server for supported extensions, controls and features. Some advanced settings of the LDAP provider will be then automatically configured based on the capabilities/extensions/features supported by LDAP server. For example if LDAPv3 Password Modify extension is supported by LDAP server, corresponding switch will be enabled for LDAP provider. diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js index 75e84ce1e3..82bd5f9545 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js @@ -14,7 +14,7 @@ module.controller('UserRoleMappingCtrl', function($scope, $http, $route, realm, $scope.clientMappings = []; $scope.dummymodel = []; $scope.selectedClient = null; - + $scope.realmMappings = RealmRoleMapping.query({realm : realm.realm, userId : user.id}); $scope.realmRoles = AvailableRealmRoleMapping.query({realm : realm.realm, userId : user.id}); @@ -697,7 +697,7 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, $ var msgTitle = ($scope.hasPassword ? 'Reset' : 'Set') + ' password'; var msg = 'Are you sure you want to ' + ($scope.hasPassword ? 'reset' : 'set') + ' a password for the user?'; var msgSuccess = 'The password has been ' + ($scope.hasPassword ? 'reset.' : 'set.'); - + Dialog.confirm(msgTitle, msg, function() { UserCredentials.resetPassword({ realm: realm.realm, userId: user.id }, { type : "password", value : $scope.password, temporary: $scope.temporaryPassword }, function() { Notifications.success(msgSuccess); @@ -1364,7 +1364,8 @@ module.controller('UserGroupMembershipCtrl', function($scope, $q, realm, user, U }); module.controller('LDAPUserStorageCtrl', function($scope, $location, Notifications, $route, Dialog, realm, - serverInfo, instance, Components, UserStorageOperations, RealmLDAPConnectionTester) { + serverInfo, instance, Components, UserStorageOperations, + RealmLDAPConnectionTester, $http) { console.log('LDAPUserStorageCtrl'); var providerId = 'ldap'; console.log('providerId: ' + providerId); @@ -1672,10 +1673,12 @@ module.controller('LDAPUserStorageCtrl', function($scope, $location, Notificatio Notifications.error("Error during unlink"); }); }; + var initConnectionTest = function(testAction, ldapConfig) { return { action: testAction, connectionUrl: ldapConfig.connectionUrl && ldapConfig.connectionUrl[0], + authType: ldapConfig.authType && ldapConfig.authType[0], bindDn: ldapConfig.bindDn && ldapConfig.bindDn[0], bindCredential: ldapConfig.bindCredential && ldapConfig.bindCredential[0], useTruststoreSpi: ldapConfig.useTruststoreSpi && ldapConfig.useTruststoreSpi[0], @@ -1703,7 +1706,23 @@ module.controller('LDAPUserStorageCtrl', function($scope, $location, Notificatio }); } + $scope.queryAndSetLdapSupportedExtensions = function() { + console.log('LDAPCtrl: getLdapSupportedExtensions'); + const PASSWORD_MODIFY_OID = '1.3.6.1.4.1.4203.1.11.1'; + $http.post( + `${authUrl}/admin/realms/${realm.realm}/ldap-server-capabilities`, + initConnectionTest("queryServerCapabilities", $scope.instance.config)).then( + (response) => { + Notifications.success("LDAP supported extensions successfully requested."); + const ldapOids = response.data; + if (angular.isArray(ldapOids)) { + const passwordModifyOid = ldapOids.filter(ldapOid => ldapOid.oid === PASSWORD_MODIFY_OID); + $scope.instance.config['usePasswordModifyExtendedOp'][0] = `${passwordModifyOid.length > 0}`; + } + }, + () => Notifications.error("Error when trying to request supported extensions of LDAP. See server.log for details.")); + } }); diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap.html b/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap.html index c57d10de94..6e250223d6 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap.html @@ -127,43 +127,6 @@ {{:: 'ldap.users-dn.tooltip' | translate}} -

- -
-
- -
-
- {{:: 'ldap.authentication-type.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.startTls.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.bind-dn.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.bind-credential.tooltip' | translate}} - -
@@ -184,6 +147,60 @@
{{:: 'ldap.search-scope.tooltip' | translate}}
+
+ +
+
+ +
+
+ {{:: 'ldap.authentication-type.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.bind-dn.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.bind-credential.tooltip' | translate}} + +
+ + +
+ {{:: 'advanced-ldap-settings' | translate}} +
+ +
+ +
+ {{:: 'ldap.startTls.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.usePasswordModifyExtendedOp.tooltip' | translate}} +
+ + {{:: 'ldap-query-supported-extensions.tooltip' | translate}} +
+
@@ -211,65 +228,6 @@
{{:: 'ldap.use-truststore-spi.tooltip' | translate}}
-
- -
- -
- {{:: 'ldap.connection-pooling.tooltip' | translate}} - -
-
- -
- -
- {{:: 'ldap.connection-pooling.authentication.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.connection-pooling.debug.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.connection-pooling.initsize.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.connection-pooling.maxsize.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.connection-pooling.prefsize.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.connection-pooling.protocol.tooltip' | translate}} -
-
- -
- -
- {{:: 'ldap.connection-pooling.timeout.tooltip' | translate}} -
@@ -294,7 +252,67 @@
- {{:: 'kerberos-integration' | translate}} + {{:: 'connection-pooling' | translate}} +
+ +
+ +
+ {{:: 'ldap.connection-pooling.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.connection-pooling.authentication.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.connection-pooling.debug.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.connection-pooling.initsize.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.connection-pooling.maxsize.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.connection-pooling.prefsize.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.connection-pooling.protocol.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'ldap.connection-pooling.timeout.tooltip' | translate}} +
+
+ +
+ {{:: 'kerberos-integration' | translate}}
@@ -340,7 +358,7 @@
- {{:: 'sync-settings' | translate}} + {{:: 'sync-settings' | translate}}
@@ -379,7 +397,7 @@
- {{:: 'user-storage-cache-policy' | translate}} + {{:: 'user-storage-cache-policy' | translate}}
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 a155a6b3e3..ed6d25fe96 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 @@ -22,6 +22,7 @@ import org.apache.commons.io.IOUtils; import org.apache.commons.lang.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.ldif.LdifEntry; import org.apache.directory.api.ldap.model.ldif.LdifReader; import org.apache.directory.api.ldap.model.schema.SchemaManager; @@ -33,6 +34,7 @@ import org.apache.directory.server.core.factory.DefaultDirectoryServiceFactory; import org.apache.directory.server.core.factory.JdbmPartitionFactory; import org.apache.directory.server.core.normalization.NormalizationInterceptor; import org.apache.directory.server.ldap.LdapServer; +import org.apache.directory.server.ldap.handlers.extended.PwdModifyHandler; import org.apache.directory.server.protocol.shared.transport.TcpTransport; import org.apache.directory.server.protocol.shared.transport.Transport; import org.jboss.logging.Logger; @@ -255,6 +257,13 @@ public class LDAPEmbeddedServer { // Associate the DS to this LdapServer ldapServer.setDirectoryService( directoryService ); + // Support for extended password modify as described in https://tools.ietf.org/html/rfc3062 + try { + ldapServer.addExtendedOperationHandler(new PwdModifyHandler()); + } catch (LdapException le) { + throw new IllegalStateException("It wasn't possible to add PwdModifyHandler"); + } + // Propagate the anonymous flag to the DS directoryService.setAllowAnonymousAccess(false);