From c75dcb90c2bb32e2ced12fccc7d6bf2ddf14cda0 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Fri, 4 Nov 2016 21:25:47 -0400 Subject: [PATCH] ldap port --- .../idm/StorageProviderRepresentation.java | 90 -- .../kerberos/CommonKerberosConfig.java | 23 +- .../ldap/idm/model/GroupTreeResolverTest.java | 0 .../ldap/idm/model/LDAPDnTest.java | 0 .../idm/model/LDAPMappersComparatorTest.java | 0 federation/ldap2/pom.xml | 102 ++ .../org/keycloak/storage/ldap/LDAPConfig.java | 182 ++++ .../ldap/LDAPIdentityStoreRegistry.java | 95 ++ .../storage/ldap/LDAPStorageProvider.java | 645 +++++++++++++ .../ldap/LDAPStorageProviderFactory.java | 437 +++++++++ .../org/keycloak/storage/ldap/LDAPUtils.java | 286 ++++++ .../ldap/ReadonlyLDAPUserModelDelegate.java | 57 ++ .../ldap/UnsyncedLDAPUserModelDelegate.java | 37 + .../ldap/WritableLDAPUserModelDelegate.java | 41 + .../storage/ldap/idm/model/LDAPDn.java | 153 +++ .../storage/ldap/idm/model/LDAPObject.java | 157 ++++ .../storage/ldap/idm/query/Condition.java | 42 + .../keycloak/storage/ldap/idm/query/Sort.java | 40 + .../idm/query/internal/BetweenCondition.java | 53 ++ .../idm/query/internal/CustomLDAPFilter.java | 51 + .../idm/query/internal/EqualCondition.java | 58 ++ .../query/internal/GreaterThanCondition.java | 53 ++ .../ldap/idm/query/internal/InCondition.java | 48 + .../ldap/idm/query/internal/LDAPQuery.java | 213 +++++ .../internal/LDAPQueryConditionsBuilder.java | 84 ++ .../idm/query/internal/LessThanCondition.java | 53 ++ .../internal/NamedParameterCondition.java | 50 + .../ldap/idm/query/internal/OrCondition.java | 59 ++ .../storage/ldap/idm/store/IdentityStore.java | 98 ++ .../idm/store/ldap/LDAPIdentityStore.java | 423 +++++++++ .../idm/store/ldap/LDAPOperationManager.java | 562 ++++++++++++ .../storage/ldap/idm/store/ldap/LDAPUtil.java | 175 ++++ .../kerberos/LDAPProviderKerberosConfig.java | 39 + .../mappers/AbstractLDAPStorageMapper.java | 106 +++ .../AbstractLDAPStorageMapperFactory.java | 89 ++ .../mappers/FullNameLDAPStorageMapper.java | 195 ++++ .../FullNameLDAPStorageMapperFactory.java | 124 +++ .../HardcodedLDAPRoleStorageMapper.java | 126 +++ ...HardcodedLDAPRoleStorageMapperFactory.java | 78 ++ .../ldap/mappers/LDAPMappersComparator.java | 114 +++ .../ldap/mappers/LDAPStorageMapper.java | 125 +++ .../ldap/mappers/LDAPStorageMapperBridge.java | 98 ++ .../mappers/LDAPStorageMapperFactory.java | 99 ++ .../ldap/mappers/LDAPStorageMapperSpi.java | 50 + .../storage/ldap/mappers/PasswordUpdated.java | 29 + .../mappers/TxAwareLDAPUserModelDelegate.java | 136 +++ .../UserAttributeLDAPStorageMapper.java | 369 ++++++++ ...UserAttributeLDAPStorageMapperFactory.java | 110 +++ .../membership/CommonLDAPGroupMapper.java | 32 + .../CommonLDAPGroupMapperConfig.java | 87 ++ .../membership/LDAPGroupMapperMode.java | 46 + .../mappers/membership/MembershipType.java | 157 ++++ .../membership/UserRolesRetrieveStrategy.java | 128 +++ .../group/GroupLDAPStorageMapper.java | 648 +++++++++++++ .../group/GroupLDAPStorageMapperFactory.java | 206 +++++ .../membership/group/GroupMapperConfig.java | 125 +++ .../membership/group/GroupTreeResolver.java | 204 ++++ .../role/RoleLDAPStorageMapper.java | 450 +++++++++ .../role/RoleLDAPStorageMapperFactory.java | 200 ++++ .../membership/role/RoleMapperConfig.java | 113 +++ .../MSADUserAccountControlStorageMapper.java | 289 ++++++ ...serAccountControlStorageMapperFactory.java | 62 ++ .../ldap/mappers/msad/UserAccountControl.java | 75 ++ .../services/org.keycloak.provider.Spi | 18 + ...eycloak.storage.UserStorageProviderFactory | 1 + ...rage.ldap.mappers.LDAPStorageMapperFactory | 6 + .../ldap/idm/model/GroupTreeResolverTest.java | 125 +++ .../storage/ldap/idm/model/LDAPDnTest.java | 49 + .../idm/model/LDAPMappersComparatorTest.java | 116 +++ federation/pom.xml | 3 +- .../keycloak/models/jpa/JpaUserProvider.java | 22 +- .../org/keycloak/models/jpa/RealmAdapter.java | 6 +- .../mongo/keycloak/adapters/RealmAdapter.java | 6 +- pom.xml | 5 + .../keycloak/component/ComponentFactory.java | 2 +- .../keycloak/component/ComponentModel.java | 2 +- .../component/SubComponentFactory.java | 40 + .../models/utils/KeycloakModelUtils.java | 23 + .../ProviderConfigurationBuilder.java | 6 + .../storage/UserStorageProviderFactory.java | 2 +- .../keys/AbstractRsaKeyProviderFactory.java | 3 +- .../keys/GeneratedRsaKeyProviderFactory.java | 6 +- .../keys/JavaKeystoreKeyProviderFactory.java | 5 +- .../keycloak/keys/RsaKeyProviderFactory.java | 5 +- ...stractClientRegistrationPolicyFactory.java | 3 +- ...lientsClientRegistrationPolicyFactory.java | 3 +- ...edHostClientRegistrationPolicyFactory.java | 3 +- .../resources/admin/ComponentResource.java | 34 + .../components/TestImplProviderFactory.java | 3 +- testsuite/integration/pom.xml | 4 + .../storage/ldap/LDAPExampleServlet.java | 63 ++ .../ldap/LDAPProvidersIntegrationTest.java | 868 ++++++++++++++++++ .../storage/ldap/LDAPTestConfiguration.java | 149 +++ .../storage/ldap/LDAPTestUtils.java | 296 ++++++ .../admin/resources/js/controllers/users.js | 23 +- .../resources/partials/user-credentials.html | 2 +- 96 files changed, 10837 insertions(+), 141 deletions(-) delete mode 100755 core/src/main/java/org/keycloak/representations/idm/StorageProviderRepresentation.java rename federation/ldap/src/test/java/org/keycloak/{federation => storage}/ldap/idm/model/GroupTreeResolverTest.java (100%) rename federation/ldap/src/test/java/org/keycloak/{federation => storage}/ldap/idm/model/LDAPDnTest.java (100%) rename federation/ldap/src/test/java/org/keycloak/{federation => storage}/ldap/idm/model/LDAPMappersComparatorTest.java (100%) create mode 100755 federation/ldap2/pom.xml create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPConfig.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPIdentityStoreRegistry.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPUtils.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/ReadonlyLDAPUserModelDelegate.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/UnsyncedLDAPUserModelDelegate.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/WritableLDAPUserModelDelegate.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/model/LDAPDn.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/model/LDAPObject.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/Condition.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/Sort.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/BetweenCondition.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/CustomLDAPFilter.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/EqualCondition.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/GreaterThanCondition.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/InCondition.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQuery.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQueryConditionsBuilder.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LessThanCondition.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/NamedParameterCondition.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/OrCondition.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/IdentityStore.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPIdentityStore.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPOperationManager.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPUtil.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/kerberos/LDAPProviderKerberosConfig.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapper.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapperFactory.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapper.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapperFactory.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPRoleStorageMapper.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPRoleStorageMapperFactory.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPMappersComparator.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapper.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperBridge.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperFactory.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperSpi.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/PasswordUpdated.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/TxAwareLDAPUserModelDelegate.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapperFactory.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/CommonLDAPGroupMapper.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/CommonLDAPGroupMapperConfig.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/LDAPGroupMapperMode.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/MembershipType.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/UserRolesRetrieveStrategy.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupLDAPStorageMapper.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupLDAPStorageMapperFactory.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupMapperConfig.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupTreeResolver.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleLDAPStorageMapper.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleLDAPStorageMapperFactory.java create mode 100755 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleMapperConfig.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapperFactory.java create mode 100644 federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/UserAccountControl.java create mode 100755 federation/ldap2/src/main/resources/META-INF/services/org.keycloak.provider.Spi create mode 100644 federation/ldap2/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory create mode 100644 federation/ldap2/src/main/resources/META-INF/services/org.keycloak.storage.ldap.mappers.LDAPStorageMapperFactory create mode 100644 federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/GroupTreeResolverTest.java create mode 100644 federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPDnTest.java create mode 100644 federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPMappersComparatorTest.java create mode 100644 server-spi/src/main/java/org/keycloak/component/SubComponentFactory.java create mode 100644 testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPExampleServlet.java create mode 100755 testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPProvidersIntegrationTest.java create mode 100644 testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPTestConfiguration.java create mode 100644 testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPTestUtils.java diff --git a/core/src/main/java/org/keycloak/representations/idm/StorageProviderRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/StorageProviderRepresentation.java deleted file mode 100755 index 04bf174636..0000000000 --- a/core/src/main/java/org/keycloak/representations/idm/StorageProviderRepresentation.java +++ /dev/null @@ -1,90 +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.representations.idm; - -import java.util.Map; - -/** - * @author Marek Posolda - */ -public class StorageProviderRepresentation { - - private String id; - private String displayName; - private String providerName; - private Map config; - private int priority; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getDisplayName() { - return displayName; - } - - public void setDisplayName(String displayName) { - this.displayName = displayName; - } - - public String getProviderName() { - return providerName; - } - - public void setProviderName(String providerName) { - this.providerName = providerName; - } - - - public Map getConfig() { - return config; - } - - public void setConfig(Map config) { - this.config = config; - } - - public int getPriority() { - return priority; - } - - public void setPriority(int priority) { - this.priority = priority; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - StorageProviderRepresentation that = (StorageProviderRepresentation) o; - - if (!id.equals(that.id)) return false; - - return true; - } - - @Override - public int hashCode() { - return id.hashCode(); - } -} diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/CommonKerberosConfig.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/CommonKerberosConfig.java index 7e3d6e70a2..ed6c495014 100644 --- a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/CommonKerberosConfig.java +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/CommonKerberosConfig.java @@ -18,6 +18,7 @@ package org.keycloak.federation.kerberos; import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.component.ComponentModel; import org.keycloak.models.UserFederationProviderModel; import java.util.Map; @@ -29,31 +30,41 @@ import java.util.Map; */ public abstract class CommonKerberosConfig { - private final UserFederationProviderModel providerModel; + protected UserFederationProviderModel providerModel; + protected ComponentModel componentModel; public CommonKerberosConfig(UserFederationProviderModel userFederationProvider) { this.providerModel = userFederationProvider; } + public CommonKerberosConfig(ComponentModel componentModel) { + this.componentModel = componentModel; + } + // Should be always true for KerberosFederationProvider public boolean isAllowKerberosAuthentication() { - return Boolean.valueOf(getConfig().get(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION)); + if (providerModel != null) return Boolean.valueOf(getConfig().get(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION)); + else return Boolean.valueOf(componentModel.getConfig().getFirst(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION)); } public String getKerberosRealm() { - return getConfig().get(KerberosConstants.KERBEROS_REALM); + if (providerModel != null) return getConfig().get(KerberosConstants.KERBEROS_REALM); + else return componentModel.getConfig().getFirst(KerberosConstants.KERBEROS_REALM); } public String getServerPrincipal() { - return getConfig().get(KerberosConstants.SERVER_PRINCIPAL); + if (providerModel != null) return getConfig().get(KerberosConstants.SERVER_PRINCIPAL); + else return componentModel.getConfig().getFirst(KerberosConstants.SERVER_PRINCIPAL); } public String getKeyTab() { - return getConfig().get(KerberosConstants.KEYTAB); + if (providerModel != null) return getConfig().get(KerberosConstants.KEYTAB); + else return componentModel.getConfig().getFirst(KerberosConstants.KEYTAB); } public boolean isDebug() { - return Boolean.valueOf(getConfig().get(KerberosConstants.DEBUG)); + if (providerModel != null) return Boolean.valueOf(getConfig().get(KerberosConstants.DEBUG)); + else return Boolean.valueOf(componentModel.getConfig().getFirst(KerberosConstants.DEBUG)); } protected Map getConfig() { diff --git a/federation/ldap/src/test/java/org/keycloak/federation/ldap/idm/model/GroupTreeResolverTest.java b/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/GroupTreeResolverTest.java similarity index 100% rename from federation/ldap/src/test/java/org/keycloak/federation/ldap/idm/model/GroupTreeResolverTest.java rename to federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/GroupTreeResolverTest.java diff --git a/federation/ldap/src/test/java/org/keycloak/federation/ldap/idm/model/LDAPDnTest.java b/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPDnTest.java similarity index 100% rename from federation/ldap/src/test/java/org/keycloak/federation/ldap/idm/model/LDAPDnTest.java rename to federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPDnTest.java diff --git a/federation/ldap/src/test/java/org/keycloak/federation/ldap/idm/model/LDAPMappersComparatorTest.java b/federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPMappersComparatorTest.java similarity index 100% rename from federation/ldap/src/test/java/org/keycloak/federation/ldap/idm/model/LDAPMappersComparatorTest.java rename to federation/ldap/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPMappersComparatorTest.java diff --git a/federation/ldap2/pom.xml b/federation/ldap2/pom.xml new file mode 100755 index 0000000000..dccdee0233 --- /dev/null +++ b/federation/ldap2/pom.xml @@ -0,0 +1,102 @@ + + + + + keycloak-parent + org.keycloak + 2.4.0.CR1-SNAPSHOT + ../../pom.xml + + 4.0.0 + + keycloak-ldap-storage + Keycloak LDAP UserStoreProvider + + + + 1.8 + 1.8 + + + + + org.keycloak + keycloak-core + provided + + + org.keycloak + keycloak-server-spi + provided + + + org.keycloak + keycloak-kerberos-federation + provided + + + org.jboss.resteasy + resteasy-jaxrs + provided + + + log4j + log4j + + + org.slf4j + slf4j-api + + + org.slf4j + slf4j-simple + + + + + org.jboss.logging + jboss-logging + provided + + + junit + junit + test + + + org.jboss.spec.javax.transaction + jboss-transaction-api_1.2_spec + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + + diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPConfig.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPConfig.java new file mode 100644 index 0000000000..e5d497b2e0 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPConfig.java @@ -0,0 +1,182 @@ +/* + * 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.storage.ldap; + +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.models.LDAPConstants; + +import javax.naming.directory.SearchControls; +import java.util.Collection; +import java.util.HashSet; +import java.util.Properties; +import java.util.Set; + +/** + * @author Marek Posolda + * + */ +public class LDAPConfig { + + private final MultivaluedHashMap config; + + public LDAPConfig(MultivaluedHashMap config) { + this.config = config; + } + + public String getConnectionUrl() { + return config.getFirst(LDAPConstants.CONNECTION_URL); + } + + public String getFactoryName() { + // hardcoded for now + return "com.sun.jndi.ldap.LdapCtxFactory"; + } + + public String getAuthType() { + String value = config.getFirst(LDAPConstants.AUTH_TYPE); + if (value == null) { + return LDAPConstants.AUTH_TYPE_SIMPLE; + } else { + return value; + } + } + + public String getUseTruststoreSpi() { + return config.getFirst(LDAPConstants.USE_TRUSTSTORE_SPI); + } + + public String getUsersDn() { + String usersDn = config.getFirst(LDAPConstants.USERS_DN); + + if (usersDn == null) { + // Just for the backwards compatibility 1.2 -> 1.3 . Should be removed later. + usersDn = config.getFirst("userDnSuffix"); + } + + return usersDn; + } + + public Collection getUserObjectClasses() { + String objClassesCfg = config.getFirst(LDAPConstants.USER_OBJECT_CLASSES); + String objClassesStr = (objClassesCfg != null && objClassesCfg.length() > 0) ? objClassesCfg.trim() : "inetOrgPerson,organizationalPerson"; + + String[] objectClasses = objClassesStr.split(","); + + // Trim them + Set userObjClasses = new HashSet<>(); + for (int i=0 ; i 1.3 . Should be removed later. + rdn = LDAPConstants.CN; + } + + } + return rdn; + } + + + public String getCustomUserSearchFilter() { + String customFilter = config.getFirst(LDAPConstants.CUSTOM_USER_SEARCH_FILTER); + if (customFilter != null) { + customFilter = customFilter.trim(); + if (customFilter.length() > 0) { + return customFilter; + } + } + return null; + } + + public LDAPStorageProviderFactory.EditMode getEditMode() { + String editModeString = config.getFirst(LDAPConstants.EDIT_MODE); + if (editModeString == null) { + return LDAPStorageProviderFactory.EditMode.READ_ONLY; + } else { + return LDAPStorageProviderFactory.EditMode.valueOf(editModeString); + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPIdentityStoreRegistry.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPIdentityStoreRegistry.java new file mode 100644 index 0000000000..7dc3086ba4 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPIdentityStoreRegistry.java @@ -0,0 +1,95 @@ +/* + * 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.storage.ldap; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.storage.ldap.idm.store.ldap.LDAPIdentityStore; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author Marek Posolda + */ +public class LDAPIdentityStoreRegistry { + + private static final Logger logger = Logger.getLogger(LDAPIdentityStoreRegistry.class); + + private Map ldapStores = new ConcurrentHashMap(); + + public LDAPIdentityStore getLdapStore(ComponentModel model) { + LDAPIdentityStoreContext context = ldapStores.get(model.getId()); + + // Ldap config might have changed for the realm. In this case, we must re-initialize + MultivaluedHashMap config = model.getConfig(); + if (context == null || !config.equals(context.config)) { + logLDAPConfig(model.getName(), config); + + LDAPIdentityStore store = createLdapIdentityStore(config); + context = new LDAPIdentityStoreContext(config, store); + ldapStores.put(model.getId(), context); + } + return context.store; + } + + // Don't log LDAP password + private void logLDAPConfig(String fedProviderDisplayName, MultivaluedHashMap ldapConfig) { + MultivaluedHashMap copy = new MultivaluedHashMap(ldapConfig); + copy.remove(LDAPConstants.BIND_CREDENTIAL); + logger.infof("Creating new LDAP based partition manager for the Federation provider: " + fedProviderDisplayName + ", LDAP Configuration: " + copy); + } + + /** + * @param ldapConfig from realm + * @return PartitionManager instance based on LDAP store + */ + public static LDAPIdentityStore createLdapIdentityStore(MultivaluedHashMap ldapConfig) { + LDAPConfig cfg = new LDAPConfig(ldapConfig); + + checkSystemProperty("com.sun.jndi.ldap.connect.pool.authentication", "none simple"); + checkSystemProperty("com.sun.jndi.ldap.connect.pool.initsize", "1"); + checkSystemProperty("com.sun.jndi.ldap.connect.pool.maxsize", "1000"); + checkSystemProperty("com.sun.jndi.ldap.connect.pool.prefsize", "5"); + checkSystemProperty("com.sun.jndi.ldap.connect.pool.timeout", "300000"); + checkSystemProperty("com.sun.jndi.ldap.connect.pool.protocol", "plain"); + checkSystemProperty("com.sun.jndi.ldap.connect.pool.debug", "off"); + + return new LDAPIdentityStore(cfg); + } + + private static void checkSystemProperty(String name, String defaultValue) { + if (System.getProperty(name) == null) { + System.setProperty(name, defaultValue); + } + } + + + private class LDAPIdentityStoreContext { + + private LDAPIdentityStoreContext(MultivaluedHashMap config, LDAPIdentityStore store) { + this.config = config; + this.store = store; + } + + private MultivaluedHashMap config; + private LDAPIdentityStore store; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java new file mode 100755 index 0000000000..272dfe5ece --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java @@ -0,0 +1,645 @@ +/* + * 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.storage.ldap; + +import org.jboss.logging.Logger; +import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.component.ComponentModel; +import org.keycloak.credential.CredentialAuthentication; +import org.keycloak.credential.CredentialInput; +import org.keycloak.credential.CredentialInputUpdater; +import org.keycloak.credential.CredentialInputValidator; +import org.keycloak.credential.CredentialModel; +import org.keycloak.federation.kerberos.impl.KerberosUsernamePasswordAuthenticator; +import org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator; +import org.keycloak.models.CredentialValidationOutput; +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.ModelReadOnlyException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.UserManager; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +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.kerberos.LDAPProviderKerberosConfig; +import org.keycloak.storage.ldap.mappers.LDAPMappersComparator; +import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.PasswordUpdated; +import org.keycloak.storage.user.ImportedUserValidation; +import org.keycloak.storage.user.UserLookupProvider; +import org.keycloak.storage.user.UserQueryProvider; +import org.keycloak.storage.user.UserRegistrationProvider; + +import javax.naming.AuthenticationException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @author Marek Posolda + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class LDAPStorageProvider implements UserStorageProvider, + CredentialInputValidator, + CredentialInputUpdater, + CredentialAuthentication, + UserLookupProvider, + UserRegistrationProvider, + UserQueryProvider, + ImportedUserValidation { + private static final Logger logger = Logger.getLogger(LDAPStorageProvider.class); + + protected LDAPStorageProviderFactory factory; + protected KeycloakSession session; + protected ComponentModel model; + protected LDAPIdentityStore ldapIdentityStore; + protected LDAPStorageProviderFactory.EditMode editMode; + protected LDAPProviderKerberosConfig kerberosConfig; + protected PasswordUpdated updater; + + protected final Set supportedCredentialTypes = new HashSet<>(); + + public LDAPStorageProvider(LDAPStorageProviderFactory factory, KeycloakSession session, ComponentModel model, LDAPIdentityStore ldapIdentityStore) { + this.factory = factory; + this.session = session; + this.model = model; + this.ldapIdentityStore = ldapIdentityStore; + this.kerberosConfig = new LDAPProviderKerberosConfig(model); + this.editMode = ldapIdentityStore.getConfig().getEditMode(); + + supportedCredentialTypes.add(UserCredentialModel.PASSWORD); + if (kerberosConfig.isAllowKerberosAuthentication()) { + supportedCredentialTypes.add(UserCredentialModel.KERBEROS); + } + } + + public void setUpdater(PasswordUpdated updater) { + this.updater = updater; + } + + public KeycloakSession getSession() { + return session; + } + + public LDAPIdentityStore getLdapIdentityStore() { + return this.ldapIdentityStore; + } + + public LDAPStorageProviderFactory.EditMode getEditMode() { + return editMode; + } + + public ComponentModel getModel() { + return model; + } + + @Override + public UserModel validate(RealmModel realm, UserModel local) { + LDAPObject ldapObject = loadAndValidateUser(realm, local); + if (ldapObject == null) { + return null; + } + + return proxy(realm, local, ldapObject); + } + + protected UserModel proxy(RealmModel realm, UserModel local, LDAPObject ldapObject) { + UserModel proxied = local; + switch (editMode) { + case READ_ONLY: + proxied = new ReadonlyLDAPUserModelDelegate(local, this); + break; + case WRITABLE: + proxied = new WritableLDAPUserModelDelegate(local, this, ldapObject); + break; + case UNSYNCED: + proxied = new UnsyncedLDAPUserModelDelegate(local, this); + } + + List mappers = realm.getComponents(model.getId(), LDAPStorageMapper.class.getName()); + List sortedMappers = sortMappersAsc(mappers); + for (ComponentModel mapperModel : sortedMappers) { + LDAPStorageMapper ldapMapper = getMapper(mapperModel); + proxied = ldapMapper.proxy(mapperModel, this, ldapObject, proxied, realm); + } + + return proxied; + } + + @Override + public boolean supportsCredentialAuthenticationFor(String type) { + return type.equals(CredentialModel.KERBEROS) && kerberosConfig.isAllowKerberosAuthentication(); + } + + @Override + public List searchForUserByUserAttribute(String attrName, String attrValue, RealmModel realm) { + return Collections.EMPTY_LIST; + } + + @Override + public void grantToAllUsers(RealmModel realm, RoleModel role) { + + } + + public boolean synchronizeRegistrations() { + return "true".equalsIgnoreCase(model.getConfig().getFirst(LDAPConstants.SYNC_REGISTRATIONS)) && editMode == LDAPStorageProviderFactory.EditMode.WRITABLE; + } + + @Override + public UserModel addUser(RealmModel realm, String username) { + if (editMode == LDAPStorageProviderFactory.EditMode.READ_ONLY || editMode == LDAPStorageProviderFactory.EditMode.UNSYNCED) throw new IllegalStateException("Registration is not supported by this ldap server"); + if (!synchronizeRegistrations()) throw new IllegalStateException("Registration is not supported by this ldap server"); + UserModel user = session.userLocalStorage().addUser(realm, username); + user.setFederationLink(model.getId()); + LDAPObject ldapUser = LDAPUtils.addUserToLDAP(this, realm, user); + LDAPUtils.checkUuid(ldapUser, ldapIdentityStore.getConfig()); + user.setSingleAttribute(LDAPConstants.LDAP_ID, ldapUser.getUuid()); + user.setSingleAttribute(LDAPConstants.LDAP_ENTRY_DN, ldapUser.getDn().toString()); + + return proxy(realm, user, ldapUser); + } + + @Override + public boolean removeUser(RealmModel realm, UserModel user) { + if (editMode == LDAPStorageProviderFactory.EditMode.READ_ONLY || editMode == LDAPStorageProviderFactory.EditMode.UNSYNCED) { + logger.warnf("User '%s' can't be deleted in LDAP as editMode is '%s'. Deleting user just from Keycloak DB, but he will be re-imported from LDAP again once searched in Keycloak", user.getUsername(), editMode.toString()); + return true; + } + + LDAPObject ldapObject = loadAndValidateUser(realm, user); + if (ldapObject == null) { + logger.warnf("User '%s' can't be deleted from LDAP as it doesn't exist here", user.getUsername()); + return false; + } + + ldapIdentityStore.remove(ldapObject); + return true; + } + + @Override + public UserModel getUserById(String id, RealmModel realm) { + StorageId storageId = new StorageId(id); + return getUserByUsername(storageId.getExternalId(), realm); + } + + @Override + public int getUsersCount(RealmModel realm) { + return 0; + } + + @Override + public List getUsers(RealmModel realm) { + return Collections.EMPTY_LIST; + } + + @Override + public List getUsers(RealmModel realm, int firstResult, int maxResults) { + return Collections.EMPTY_LIST; + } + + @Override + public List searchForUser(String search, RealmModel realm) { + return searchForUser(search, realm, 0, Integer.MAX_VALUE - 1); + } + + @Override + public List searchForUser(String search, RealmModel realm, int firstResult, int maxResults) { + Map attributes = new HashMap(); + int spaceIndex = search.lastIndexOf(' '); + if (spaceIndex > -1) { + String firstName = search.substring(0, spaceIndex).trim(); + String lastName = search.substring(spaceIndex).trim(); + attributes.put(UserModel.FIRST_NAME, firstName); + attributes.put(UserModel.LAST_NAME, lastName); + } else if (search.indexOf('@') > -1) { + attributes.put(UserModel.USERNAME, search.trim().toLowerCase()); + attributes.put(UserModel.EMAIL, search.trim().toLowerCase()); + } else { + attributes.put(UserModel.LAST_NAME, search.trim()); + attributes.put(UserModel.USERNAME, search.trim().toLowerCase()); + } + return searchForUser(attributes, realm, firstResult, maxResults); + } + + @Override + public List searchForUser(Map params, RealmModel realm) { + return searchForUser(params, realm, 0, Integer.MAX_VALUE - 1); + } + + @Override + public List searchForUser(Map params, RealmModel realm, int firstResult, int maxResults) { + List searchResults =new LinkedList(); + + List ldapUsers = searchLDAP(realm, params, maxResults + firstResult); + int counter = 0; + for (LDAPObject ldapUser : ldapUsers) { + if (counter++ < firstResult) continue; + String ldapUsername = LDAPUtils.getUsername(ldapUser, this.ldapIdentityStore.getConfig()); + if (session.userLocalStorage().getUserByUsername(ldapUsername, realm) == null) { + UserModel imported = importUserFromLDAP(session, realm, ldapUser); + searchResults.add(imported); + } + } + + return searchResults; + } + + @Override + public List getGroupMembers(RealmModel realm, GroupModel group) { + return getGroupMembers(realm, group, 0, Integer.MAX_VALUE - 1); + } + + @Override + public List getGroupMembers(RealmModel realm, GroupModel group, int firstResult, int maxResults) { + List mappers = realm.getComponents(model.getId(), LDAPStorageMapper.class.getName()); + List sortedMappers = sortMappersAsc(mappers); + for (ComponentModel mapperModel : sortedMappers) { + LDAPStorageMapper ldapMapper = getMapper(mapperModel); + List users = ldapMapper.getGroupMembers(mapperModel, this, realm, group, firstResult, maxResults); + + // Sufficient for now + if (users.size() > 0) { + return users; + } + } + return Collections.emptyList(); + } + + public List loadUsersByUsernames(List usernames, RealmModel realm) { + List result = new ArrayList<>(); + for (String username : usernames) { + UserModel kcUser = session.users().getUserByUsername(username, realm); + if (kcUser == null) { + logger.warnf("User '%s' referenced by membership wasn't found in LDAP", username); + } else if (!model.getId().equals(kcUser.getFederationLink())) { + logger.warnf("Incorrect federation provider of user '%s'", kcUser.getUsername()); + } else { + result.add(kcUser); + } + } + return result; + } + + protected List searchLDAP(RealmModel realm, Map attributes, int maxResults) { + + List results = new ArrayList(); + if (attributes.containsKey(UserModel.USERNAME)) { + LDAPObject user = loadLDAPUserByUsername(realm, attributes.get(UserModel.USERNAME)); + if (user != null) { + results.add(user); + } + } + + if (attributes.containsKey(UserModel.EMAIL)) { + LDAPObject user = queryByEmail(realm, attributes.get(UserModel.EMAIL)); + if (user != null) { + results.add(user); + } + } + + if (attributes.containsKey(UserModel.FIRST_NAME) || attributes.containsKey(UserModel.LAST_NAME)) { + LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm); + LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder(); + + // Mapper should replace parameter with correct LDAP mapped attributes + if (attributes.containsKey(UserModel.FIRST_NAME)) { + ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.FIRST_NAME, attributes.get(UserModel.FIRST_NAME))); + } + if (attributes.containsKey(UserModel.LAST_NAME)) { + ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.LAST_NAME, attributes.get(UserModel.LAST_NAME))); + } + + List ldapObjects = ldapQuery.getResultList(); + results.addAll(ldapObjects); + } + + return results; + } + + /** + * @param local + * @return ldapUser corresponding to local user or null if user is no longer in LDAP + */ + protected LDAPObject loadAndValidateUser(RealmModel realm, UserModel local) { + LDAPObject ldapUser = loadLDAPUserByUsername(realm, local.getUsername()); + if (ldapUser == null) { + return null; + } + LDAPUtils.checkUuid(ldapUser, ldapIdentityStore.getConfig()); + + if (ldapUser.getUuid().equals(local.getFirstAttribute(LDAPConstants.LDAP_ID))) { + return ldapUser; + } else { + logger.warnf("LDAP User invalid. ID doesn't match. ID from LDAP [%s], LDAP ID from local DB: [%s]", ldapUser.getUuid(), local.getFirstAttribute(LDAPConstants.LDAP_ID)); + return null; + } + } + + @Override + public UserModel getUserByUsername(String username, RealmModel realm) { + LDAPObject ldapUser = loadLDAPUserByUsername(realm, username); + if (ldapUser == null) { + return null; + } + + return importUserFromLDAP(session, realm, ldapUser); + } + + protected UserModel importUserFromLDAP(KeycloakSession session, RealmModel realm, LDAPObject ldapUser) { + String ldapUsername = LDAPUtils.getUsername(ldapUser, ldapIdentityStore.getConfig()); + LDAPUtils.checkUuid(ldapUser, ldapIdentityStore.getConfig()); + + UserModel imported = session.userLocalStorage().addUser(realm, ldapUsername); + imported.setEnabled(true); + + List mappers = realm.getComponents(model.getId(), LDAPStorageMapper.class.getName()); + List sortedMappers = sortMappersDesc(mappers); + for (ComponentModel mapperModel : sortedMappers) { + if (logger.isTraceEnabled()) { + logger.tracef("Using mapper %s during import user from LDAP", mapperModel); + } + LDAPStorageMapper ldapMapper = getMapper(mapperModel); + ldapMapper.onImportUserFromLDAP(mapperModel, this, ldapUser, imported, realm, true); + } + + String userDN = ldapUser.getDn().toString(); + imported.setFederationLink(model.getId()); + imported.setSingleAttribute(LDAPConstants.LDAP_ID, ldapUser.getUuid()); + imported.setSingleAttribute(LDAPConstants.LDAP_ENTRY_DN, userDN); + + logger.debugf("Imported new user from LDAP to Keycloak DB. Username: [%s], Email: [%s], LDAP_ID: [%s], LDAP Entry DN: [%s]", imported.getUsername(), imported.getEmail(), + ldapUser.getUuid(), userDN); + return proxy(realm, imported, ldapUser); + } + + protected LDAPObject queryByEmail(RealmModel realm, String email) { + LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm); + LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder(); + + // Mapper should replace "email" in parameter name with correct LDAP mapped attribute + Condition emailCondition = conditionsBuilder.equal(UserModel.EMAIL, email); + ldapQuery.addWhereCondition(emailCondition); + + return ldapQuery.getFirstResult(); + } + + + @Override + public UserModel getUserByEmail(String email, RealmModel realm) { + LDAPObject ldapUser = queryByEmail(realm, email); + if (ldapUser == null) { + return null; + } + + // Check here if user already exists + String ldapUsername = LDAPUtils.getUsername(ldapUser, ldapIdentityStore.getConfig()); + if (session.userLocalStorage().getUserByUsername(ldapUsername, realm) != null) { + throw new ModelDuplicateException("User with username '" + ldapUsername + "' already exists in Keycloak. It conflicts with LDAP user with email '" + email + "'"); + } + + return importUserFromLDAP(session, realm, ldapUser); + } + + @Override + public void preRemove(RealmModel realm) { + // complete Don't think we have to do anything + } + + @Override + public void preRemove(RealmModel realm, RoleModel role) { + // TODO: Maybe mappers callback to ensure role deletion propagated to LDAP by RoleLDAPFederationMapper? + } + + @Override + public void preRemove(RealmModel realm, GroupModel group) { + + } + + public boolean validPassword(RealmModel realm, UserModel user, String password) { + if (kerberosConfig.isAllowKerberosAuthentication() && kerberosConfig.isUseKerberosForPasswordAuthentication()) { + // Use Kerberos JAAS (Krb5LoginModule) + KerberosUsernamePasswordAuthenticator authenticator = factory.createKerberosUsernamePasswordAuthenticator(kerberosConfig); + return authenticator.validUser(user.getUsername(), password); + } else { + // Use Naming LDAP API + LDAPObject ldapUser = loadAndValidateUser(realm, user); + + try { + ldapIdentityStore.validatePassword(ldapUser, password); + return true; + } catch (AuthenticationException ae) { + boolean processed = false; + List mappers = realm.getComponents(model.getId(), LDAPStorageMapper.class.getName()); + List sortedMappers = sortMappersDesc(mappers); + for (ComponentModel mapperModel : sortedMappers) { + if (logger.isTraceEnabled()) { + logger.tracef("Using mapper %s during import user from LDAP", mapperModel); + } + LDAPStorageMapper ldapMapper = getMapper(mapperModel); + processed = processed || ldapMapper.onAuthenticationFailure(mapperModel, this, ldapUser, user, ae, realm); + } + return processed; + } + } + } + + + @Override + public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) { + if (!CredentialModel.PASSWORD.equals(input.getType()) || ! (input instanceof UserCredentialModel)) return false; + if (editMode == LDAPStorageProviderFactory.EditMode.READ_ONLY) { + throw new ModelReadOnlyException("Federated storage is not writable"); + + } else if (editMode == LDAPStorageProviderFactory.EditMode.WRITABLE) { + LDAPIdentityStore ldapIdentityStore = getLdapIdentityStore(); + UserCredentialModel cred = (UserCredentialModel)input; + String password = cred.getValue(); + LDAPObject ldapUser = loadAndValidateUser(realm, user); + ldapIdentityStore.updatePassword(ldapUser, password); + if (updater != null) updater.passwordUpdated(user, ldapUser, input); + return true; + } else { + return false; + } + } + + @Override + public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) { + + } + + @Override + public Set getDisableableCredentialTypes(RealmModel realm, UserModel user) { + return Collections.EMPTY_SET; + } + + public Set getSupportedCredentialTypes() { + return new HashSet(this.supportedCredentialTypes); + } + + + @Override + public boolean supportsCredentialType(String credentialType) { + return getSupportedCredentialTypes().contains(credentialType); + } + + @Override + public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) { + return getSupportedCredentialTypes().contains(credentialType); + } + + @Override + public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) { + if (!(input instanceof UserCredentialModel)) return false; + if (input.getType().equals(UserCredentialModel.PASSWORD) && !session.userCredentialManager().isConfiguredLocally(realm, user, UserCredentialModel.PASSWORD)) { + return validPassword(realm, user, ((UserCredentialModel)input).getValue()); + } else { + return false; // invalid cred type + } + } + + @Override + public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput cred) { + if (!(cred instanceof UserCredentialModel)) CredentialValidationOutput.failed(); + UserCredentialModel credential = (UserCredentialModel)cred; + if (credential.getType().equals(UserCredentialModel.KERBEROS)) { + if (kerberosConfig.isAllowKerberosAuthentication()) { + String spnegoToken = credential.getValue(); + SPNEGOAuthenticator spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig); + + spnegoAuthenticator.authenticate(); + + Map state = new HashMap(); + if (spnegoAuthenticator.isAuthenticated()) { + + // TODO: This assumes that LDAP "uid" is equal to kerberos principal name. Like uid "hnelson" and kerberos principal "hnelson@KEYCLOAK.ORG". + // Check if it's correct or if LDAP attribute for mapping kerberos principal should be available (For ApacheDS it seems to be attribute "krb5PrincipalName" but on MSAD it's likely different) + String username = spnegoAuthenticator.getAuthenticatedUsername(); + UserModel user = findOrCreateAuthenticatedUser(realm, username); + + if (user == null) { + logger.warnf("Kerberos/SPNEGO authentication succeeded with username [%s], but couldn't find or create user with federation provider [%s]", username, model.getName()); + return CredentialValidationOutput.failed(); + } else { + String delegationCredential = spnegoAuthenticator.getSerializedDelegationCredential(); + if (delegationCredential != null) { + state.put(KerberosConstants.GSS_DELEGATION_CREDENTIAL, delegationCredential); + } + + return new CredentialValidationOutput(user, CredentialValidationOutput.Status.AUTHENTICATED, state); + } + } else { + state.put(KerberosConstants.RESPONSE_TOKEN, spnegoAuthenticator.getResponseToken()); + return new CredentialValidationOutput(null, CredentialValidationOutput.Status.CONTINUE, state); + } + } + } + + return CredentialValidationOutput.failed(); + } + + @Override + public void close() { + } + + /** + * Called after successful kerberos authentication + * + * @param realm realm + * @param username username without realm prefix + * @return finded or newly created user + */ + protected UserModel findOrCreateAuthenticatedUser(RealmModel realm, String username) { + UserModel user = session.userLocalStorage().getUserByUsername(username, realm); + if (user != null) { + logger.debugf("Kerberos authenticated user [%s] found in Keycloak storage", username); + if (!model.getId().equals(user.getFederationLink())) { + logger.warnf("User with username [%s] already exists, but is not linked to provider [%s]", username, model.getName()); + return null; + } else { + LDAPObject ldapObject = loadAndValidateUser(realm, user); + if (ldapObject != null) { + return proxy(realm, user, ldapObject); + } else { + logger.warnf("User with username [%s] aready exists and is linked to provider [%s] but is not valid. Stale LDAP_ID on local user is: %s", + username, model.getName(), user.getFirstAttribute(LDAPConstants.LDAP_ID)); + logger.warn("Will re-create user"); + session.getUserCache().evict(realm, user); + new UserManager(session).removeUser(realm, user, session.userLocalStorage()); + } + } + } + + // Creating user to local storage + logger.debugf("Kerberos authenticated user [%s] not in Keycloak storage. Creating him", username); + return getUserByUsername(username, realm); + } + + public LDAPObject loadLDAPUserByUsername(RealmModel realm, String username) { + LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm); + LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder(); + + String usernameMappedAttribute = this.ldapIdentityStore.getConfig().getUsernameLdapAttribute(); + Condition usernameCondition = conditionsBuilder.equal(usernameMappedAttribute, username); + ldapQuery.addWhereCondition(usernameCondition); + + LDAPObject ldapUser = ldapQuery.getFirstResult(); + if (ldapUser == null) { + return null; + } + + return ldapUser; + } + + public LDAPStorageMapper getMapper(ComponentModel mapperModel) { + LDAPStorageMapper ldapMapper = (LDAPStorageMapper) getSession().getProvider(LDAPStorageMapper.class, mapperModel); + if (ldapMapper == null) { + throw new ModelException("Can't find mapper type with ID: " + mapperModel.getProviderId()); + } + + return ldapMapper; + } + + + public List sortMappersAsc(Collection mappers) { + return LDAPMappersComparator.sortAsc(getLdapIdentityStore().getConfig(), mappers); + } + + protected List sortMappersDesc(Collection mappers) { + return LDAPMappersComparator.sortDesc(getLdapIdentityStore().getConfig(), mappers); + } + + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java new file mode 100755 index 0000000000..2ffc16f7b1 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java @@ -0,0 +1,437 @@ +/* + * 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.storage.ldap; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.federation.kerberos.CommonKerberosConfig; +import org.keycloak.federation.kerberos.impl.KerberosServerSubjectAuthenticator; +import org.keycloak.federation.kerberos.impl.KerberosUsernamePasswordAuthenticator; +import org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.KeycloakSessionTask; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.UserStorageProviderFactory; +import org.keycloak.storage.UserStorageProviderModel; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +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.mappers.FullNameLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.FullNameLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.msad.MSADUserAccountControlStorageMapperFactory; +import org.keycloak.storage.user.ImportSynchronization; +import org.keycloak.storage.user.SynchronizationResult; + +import java.util.Date; +import java.util.List; + +/** + * @author Marek Posolda + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class LDAPStorageProviderFactory implements UserStorageProviderFactory, ImportSynchronization { + + /** + * Optional type that can be by implementations to describe edit mode of federation storage + * + */ + public enum EditMode { + /** + * federation storage is read-only + */ + READ_ONLY, + /** + * federation storage is writable + * + */ + WRITABLE, + /** + * updates to user are stored locally and not synced with federation storage. + * + */ + UNSYNCED + } + + + private static final Logger logger = Logger.getLogger(LDAPStorageProviderFactory.class); + public static final String PROVIDER_NAME = "ldap2";//LDAPConstants.LDAP_PROVIDER; + + private LDAPIdentityStoreRegistry ldapStoreRegistry; + + @Override + public LDAPStorageProvider create(KeycloakSession session, ComponentModel model) { + LDAPIdentityStore ldapIdentityStore = this.ldapStoreRegistry.getLdapStore(model); + return new LDAPStorageProvider(this, session, model, ldapIdentityStore); + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { + LDAPConfig cfg = new LDAPConfig(config.getConfig()); + String customFilter = cfg.getCustomUserSearchFilter(); + LDAPUtils.validateCustomLdapFilter(customFilter); + } + + @Override + public void init(Config.Scope config) { + this.ldapStoreRegistry = new LDAPIdentityStoreRegistry(); + } + + @Override + public void close() { + this.ldapStoreRegistry = null; + } + + @Override + public String getId() { + return PROVIDER_NAME; + } + + // Best effort to create appropriate mappers according to our LDAP config + @Override + public void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) { + LDAPConfig ldapConfig = new LDAPConfig(model.getConfig()); + + boolean activeDirectory = ldapConfig.isActiveDirectory(); + EditMode editMode = ldapConfig.getEditMode(); + String readOnly = String.valueOf(editMode == EditMode.READ_ONLY || editMode == EditMode.UNSYNCED); + String usernameLdapAttribute = ldapConfig.getUsernameLdapAttribute(); + + String alwaysReadValueFromLDAP = String.valueOf(editMode==EditMode.READ_ONLY || editMode== EditMode.WRITABLE); + + ComponentModel mapperModel; + mapperModel = KeycloakModelUtils.createComponentModel("username", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.USERNAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, usernameLdapAttribute, + UserAttributeLDAPStorageMapper.READ_ONLY, readOnly, + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "false", + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + realm.addComponentModel(mapperModel); + + // CN is typically used as RDN for Active Directory deployments + if (ldapConfig.getRdnLdapAttribute().equalsIgnoreCase(LDAPConstants.CN)) { + + if (usernameLdapAttribute.equalsIgnoreCase(LDAPConstants.CN)) { + + // For AD deployments with "cn" as username, we will map "givenName" to first name + mapperModel = KeycloakModelUtils.createComponentModel("first name", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.FIRST_NAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.GIVENNAME, + UserAttributeLDAPStorageMapper.READ_ONLY, readOnly, + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, alwaysReadValueFromLDAP, + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + realm.addComponentModel(mapperModel); + + } else { + if (editMode == EditMode.WRITABLE) { + + // For AD deployments with "sAMAccountName" as username and writable, we need to map "cn" as username as well (this is needed so we can register new users from KC into LDAP) and we will map "givenName" to first name. + mapperModel = KeycloakModelUtils.createComponentModel("first name", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.FIRST_NAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.GIVENNAME, + UserAttributeLDAPStorageMapper.READ_ONLY, readOnly, + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, alwaysReadValueFromLDAP, + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + realm.addComponentModel(mapperModel); + + mapperModel = KeycloakModelUtils.createComponentModel("username-cn", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.USERNAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.CN, + UserAttributeLDAPStorageMapper.READ_ONLY, readOnly, + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "false", + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + realm.addComponentModel(mapperModel); + } else { + + // For read-only LDAP, we map "cn" as full name + mapperModel = KeycloakModelUtils.createComponentModel("full name", model.getId(), FullNameLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + FullNameLDAPStorageMapper.LDAP_FULL_NAME_ATTRIBUTE, LDAPConstants.CN, + FullNameLDAPStorageMapper.READ_ONLY, readOnly, + FullNameLDAPStorageMapper.WRITE_ONLY, "false"); + realm.addComponentModel(mapperModel); + } + } + } else { + mapperModel = KeycloakModelUtils.createComponentModel("first name", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.FIRST_NAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.CN, + UserAttributeLDAPStorageMapper.READ_ONLY, readOnly, + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, alwaysReadValueFromLDAP, + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + realm.addComponentModel(mapperModel); + } + + mapperModel = KeycloakModelUtils.createComponentModel("last name", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.LAST_NAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.SN, + UserAttributeLDAPStorageMapper.READ_ONLY, readOnly, + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, alwaysReadValueFromLDAP, + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + realm.addComponentModel(mapperModel); + + mapperModel = KeycloakModelUtils.createComponentModel("email", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.EMAIL, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.EMAIL, + UserAttributeLDAPStorageMapper.READ_ONLY, readOnly, + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "false", + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "false"); + realm.addComponentModel(mapperModel); + + String createTimestampLdapAttrName = activeDirectory ? "whenCreated" : LDAPConstants.CREATE_TIMESTAMP; + String modifyTimestampLdapAttrName = activeDirectory ? "whenChanged" : LDAPConstants.MODIFY_TIMESTAMP; + + // map createTimeStamp as read-only + mapperModel = KeycloakModelUtils.createComponentModel("creation date", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, LDAPConstants.CREATE_TIMESTAMP, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, createTimestampLdapAttrName, + UserAttributeLDAPStorageMapper.READ_ONLY, "true", + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, alwaysReadValueFromLDAP, + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "false"); + realm.addComponentModel(mapperModel); + + // map modifyTimeStamp as read-only + mapperModel = KeycloakModelUtils.createComponentModel("modify date", model.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, LDAPConstants.MODIFY_TIMESTAMP, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, modifyTimestampLdapAttrName, + UserAttributeLDAPStorageMapper.READ_ONLY, "true", + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, alwaysReadValueFromLDAP, + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "false"); + realm.addComponentModel(mapperModel); + + // MSAD specific mapper for account state propagation + if (activeDirectory) { + mapperModel = KeycloakModelUtils.createComponentModel("MSAD account controls", model.getId(), MSADUserAccountControlStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName()); + realm.addComponentModel(mapperModel); + } + } + + @Override + public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) { + syncMappers(sessionFactory, realmId, model); + + logger.infof("Sync all users from LDAP to local store: realm: %s, federation provider: %s", realmId, model.getName()); + + LDAPQuery userQuery = createQuery(sessionFactory, realmId, model); + SynchronizationResult syncResult = syncImpl(sessionFactory, userQuery, realmId, model); + + // TODO: Remove all existing keycloak users, which have federation links, but are not in LDAP. Perhaps don't check users, which were just added or updated during this sync? + + logger.infof("Sync all users finished: %s", syncResult.getStatus()); + return syncResult; + } + + @Override + public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) { + syncMappers(sessionFactory, realmId, model); + + logger.infof("Sync changed users from LDAP to local store: realm: %s, federation provider: %s, last sync time: " + lastSync, realmId, model.getName()); + + // Sync newly created and updated users + LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder(); + Condition createCondition = conditionsBuilder.greaterThanOrEqualTo(LDAPConstants.CREATE_TIMESTAMP, lastSync); + Condition modifyCondition = conditionsBuilder.greaterThanOrEqualTo(LDAPConstants.MODIFY_TIMESTAMP, lastSync); + Condition orCondition = conditionsBuilder.orCondition(createCondition, modifyCondition); + + LDAPQuery userQuery = createQuery(sessionFactory, realmId, model); + userQuery.addWhereCondition(orCondition); + SynchronizationResult result = syncImpl(sessionFactory, userQuery, realmId, model); + + logger.infof("Sync changed users finished: %s", result.getStatus()); + return result; + } + + protected void syncMappers(KeycloakSessionFactory sessionFactory, final String realmId, final ComponentModel model) { + KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { + + @Override + public void run(KeycloakSession session) { + LDAPStorageProvider ldapProvider = (LDAPStorageProvider)session.getProvider(UserStorageProvider.class, model); + RealmModel realm = session.realms().getRealm(realmId); + List mappers = realm.getComponents(model.getId(), LDAPStorageMapper.class.getName()); + for (ComponentModel mapperModel : mappers) { + LDAPStorageMapper ldapMapper = session.getProvider(LDAPStorageMapper.class, mapperModel); + SynchronizationResult syncResult = ldapMapper.syncDataFromFederationProviderToKeycloak(mapperModel, ldapProvider, session, realm); + if (syncResult.getAdded() > 0 || syncResult.getUpdated() > 0 || syncResult.getRemoved() > 0 || syncResult.getFailed() > 0) { + logger.infof("Sync of federation mapper '%s' finished. Status: %s", mapperModel.getName(), syncResult.toString()); + } + } + } + + }); + } + + protected SynchronizationResult syncImpl(KeycloakSessionFactory sessionFactory, LDAPQuery userQuery, final String realmId, final ComponentModel fedModel) { + + final SynchronizationResult syncResult = new SynchronizationResult(); + + LDAPConfig ldapConfig = new LDAPConfig(fedModel.getConfig()); + boolean pagination = ldapConfig.isPagination(); + if (pagination) { + int pageSize = ldapConfig.getBatchSizeForSync(); + + boolean nextPage = true; + while (nextPage) { + userQuery.setLimit(pageSize); + final List users = userQuery.getResultList(); + nextPage = userQuery.getPaginationContext() != null; + SynchronizationResult currentPageSync = importLdapUsers(sessionFactory, realmId, fedModel, users); + syncResult.add(currentPageSync); + } + } else { + // LDAP pagination not available. Do everything in single transaction + final List users = userQuery.getResultList(); + SynchronizationResult currentSync = importLdapUsers(sessionFactory, realmId, fedModel, users); + syncResult.add(currentSync); + } + + return syncResult; + } + + private LDAPQuery createQuery(KeycloakSessionFactory sessionFactory, final String realmId, final ComponentModel model) { + class QueryHolder { + LDAPQuery query; + } + + final QueryHolder queryHolder = new QueryHolder(); + KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { + + @Override + public void run(KeycloakSession session) { + LDAPStorageProvider ldapFedProvider = (LDAPStorageProvider)session.getProvider(UserStorageProvider.class, model); + RealmModel realm = session.realms().getRealm(realmId); + queryHolder.query = LDAPUtils.createQueryForUserSearch(ldapFedProvider, realm); + } + + }); + return queryHolder.query; + } + + protected SynchronizationResult importLdapUsers(KeycloakSessionFactory sessionFactory, final String realmId, final ComponentModel fedModel, List ldapUsers) { + final SynchronizationResult syncResult = new SynchronizationResult(); + + class BooleanHolder { + private boolean value = true; + } + final BooleanHolder exists = new BooleanHolder(); + + for (final LDAPObject ldapUser : ldapUsers) { + + try { + + // Process each user in it's own transaction to avoid global fail + KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { + + @Override + public void run(KeycloakSession session) { + LDAPStorageProvider ldapFedProvider = (LDAPStorageProvider)session.getProvider(UserStorageProvider.class, fedModel); + RealmModel currentRealm = session.realms().getRealm(realmId); + + String username = LDAPUtils.getUsername(ldapUser, ldapFedProvider.getLdapIdentityStore().getConfig()); + exists.value = true; + LDAPUtils.checkUuid(ldapUser, ldapFedProvider.getLdapIdentityStore().getConfig()); + UserModel currentUser = session.userLocalStorage().getUserByUsername(username, currentRealm); + + if (currentUser == null) { + + // Add new user to Keycloak + exists.value = false; + ldapFedProvider.importUserFromLDAP(session, currentRealm, ldapUser); + syncResult.increaseAdded(); + + } else { + if ((fedModel.getId().equals(currentUser.getFederationLink())) && (ldapUser.getUuid().equals(currentUser.getFirstAttribute(LDAPConstants.LDAP_ID)))) { + + // Update keycloak user + List federationMappers = currentRealm.getComponents(fedModel.getId(), LDAPStorageMapper.class.getName()); + List sortedMappers = ldapFedProvider.sortMappersDesc(federationMappers); + for (ComponentModel mapperModel : sortedMappers) { + LDAPStorageMapper ldapMapper = ldapFedProvider.getMapper(mapperModel); + ldapMapper.onImportUserFromLDAP(mapperModel, ldapFedProvider, ldapUser, currentUser, currentRealm, false); + } + + logger.debugf("Updated user from LDAP: %s", currentUser.getUsername()); + syncResult.increaseUpdated(); + } else { + logger.warnf("User '%s' is not updated during sync as he already exists in Keycloak database but is not linked to federation provider '%s'", username, fedModel.getName()); + syncResult.increaseFailed(); + } + } + } + + }); + } catch (ModelException me) { + logger.error("Failed during import user from LDAP", me); + syncResult.increaseFailed(); + + // Remove user if we already added him during this transaction + if (!exists.value) { + KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { + + @Override + public void run(KeycloakSession session) { + LDAPStorageProvider ldapFedProvider = (LDAPStorageProvider)session.getProvider(UserStorageProvider.class, fedModel); + RealmModel currentRealm = session.realms().getRealm(realmId); + String username = null; + try { + username = LDAPUtils.getUsername(ldapUser, ldapFedProvider.getLdapIdentityStore().getConfig()); + } catch (ModelException ignore) { + } + + if (username != null) { + UserModel existing = session.userLocalStorage().getUserByUsername(username, currentRealm); + if (existing != null) { + session.getUserCache().evict(currentRealm, existing); + session.userLocalStorage().removeUser(currentRealm, existing); + } + } + } + + }); + } + } + } + + return syncResult; + } + + protected SPNEGOAuthenticator createSPNEGOAuthenticator(String spnegoToken, CommonKerberosConfig kerberosConfig) { + KerberosServerSubjectAuthenticator kerberosAuth = createKerberosSubjectAuthenticator(kerberosConfig); + return new SPNEGOAuthenticator(kerberosConfig, kerberosAuth, spnegoToken); + } + + protected KerberosServerSubjectAuthenticator createKerberosSubjectAuthenticator(CommonKerberosConfig kerberosConfig) { + return new KerberosServerSubjectAuthenticator(kerberosConfig); + } + + protected KerberosUsernamePasswordAuthenticator createKerberosUsernamePasswordAuthenticator(CommonKerberosConfig kerberosConfig) { + return new KerberosUsernamePasswordAuthenticator(kerberosConfig); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPUtils.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPUtils.java new file mode 100755 index 0000000000..08f00a80a7 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/LDAPUtils.java @@ -0,0 +1,286 @@ +/* + * 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.storage.ldap; + +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.mappers.FederationConfigValidationException; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.storage.ldap.idm.model.LDAPDn; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +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.mappers.LDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.membership.MembershipType; + +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Allow to directly call some operations against LDAPIdentityStore. + * + * @author Marek Posolda + */ +public class LDAPUtils { + + /** + * @param ldapProvider + * @param realm + * @param user + * @return newly created LDAPObject with all the attributes, uuid and DN properly set + */ + public static LDAPObject addUserToLDAP(LDAPStorageProvider ldapProvider, RealmModel realm, UserModel user) { + LDAPObject ldapUser = new LDAPObject(); + + LDAPIdentityStore ldapStore = ldapProvider.getLdapIdentityStore(); + LDAPConfig ldapConfig = ldapStore.getConfig(); + ldapUser.setRdnAttributeName(ldapConfig.getRdnLdapAttribute()); + ldapUser.setObjectClasses(ldapConfig.getUserObjectClasses()); + + List federationMappers = realm.getComponents(ldapProvider.getModel().getId(), LDAPStorageMapper.class.getName()); + List sortedMappers = ldapProvider.sortMappersAsc(federationMappers); + for (ComponentModel mapperModel : sortedMappers) { + LDAPStorageMapper ldapMapper = ldapProvider.getMapper(mapperModel); + ldapMapper.onRegisterUserToLDAP(mapperModel, ldapProvider, ldapUser, user, realm); + } + + LDAPUtils.computeAndSetDn(ldapConfig, ldapUser); + ldapStore.add(ldapUser); + return ldapUser; + } + + public static LDAPQuery createQueryForUserSearch(LDAPStorageProvider ldapProvider, RealmModel realm) { + LDAPQuery ldapQuery = new LDAPQuery(ldapProvider); + LDAPConfig config = ldapProvider.getLdapIdentityStore().getConfig(); + ldapQuery.setSearchScope(config.getSearchScope()); + ldapQuery.setSearchDn(config.getUsersDn()); + ldapQuery.addObjectClasses(config.getUserObjectClasses()); + + String customFilter = config.getCustomUserSearchFilter(); + if (customFilter != null) { + Condition customFilterCondition = new LDAPQueryConditionsBuilder().addCustomLDAPFilter(customFilter); + ldapQuery.addWhereCondition(customFilterCondition); + } + + List mapperModels = realm.getComponents(ldapProvider.getModel().getId(), LDAPStorageMapper.class.getName()); + ldapQuery.addMappers(mapperModels); + + return ldapQuery; + } + + // ldapUser has filled attributes, but doesn't have filled dn. + private static void computeAndSetDn(LDAPConfig config, LDAPObject ldapUser) { + String rdnLdapAttrName = config.getRdnLdapAttribute(); + String rdnLdapAttrValue = ldapUser.getAttributeAsString(rdnLdapAttrName); + if (rdnLdapAttrValue == null) { + throw new ModelException("RDN Attribute [" + rdnLdapAttrName + "] is not filled. Filled attributes: " + ldapUser.getAttributes()); + } + + LDAPDn dn = LDAPDn.fromString(config.getUsersDn()); + dn.addFirst(rdnLdapAttrName, rdnLdapAttrValue); + ldapUser.setDn(dn); + } + + public static String getUsername(LDAPObject ldapUser, LDAPConfig config) { + String usernameAttr = config.getUsernameLdapAttribute(); + String ldapUsername = ldapUser.getAttributeAsString(usernameAttr); + + if (ldapUsername == null) { + throw new ModelException("User returned from LDAP has null username! Check configuration of your LDAP mappings. Mapped username LDAP attribute: " + + config.getUsernameLdapAttribute() + ", user DN: " + ldapUser.getDn() + ", attributes from LDAP: " + ldapUser.getAttributes()); + } + + return ldapUsername; + } + + public static void checkUuid(LDAPObject ldapUser, LDAPConfig config) { + if (ldapUser.getUuid() == null) { + throw new ModelException("User returned from LDAP has null uuid! Check configuration of your LDAP settings. UUID Attribute must be unique among your LDAP records and available on all the LDAP user records. " + + "If your LDAP server really doesn't support the notion of UUID, you can use any other attribute, which is supposed to be unique among LDAP users in tree. For example 'uid' or 'entryDN' . " + + "Mapped UUID LDAP attribute: " + config.getUuidLDAPAttributeName() + ", user DN: " + ldapUser.getDn()); + } + } + + + // roles & groups + + public static LDAPObject createLDAPGroup(LDAPStorageProvider ldapProvider, String groupName, String groupNameAttribute, Collection objectClasses, + String parentDn, Map> additionalAttributes) { + LDAPObject ldapObject = new LDAPObject(); + + ldapObject.setRdnAttributeName(groupNameAttribute); + ldapObject.setObjectClasses(objectClasses); + ldapObject.setSingleAttribute(groupNameAttribute, groupName); + + LDAPDn roleDn = LDAPDn.fromString(parentDn); + roleDn.addFirst(groupNameAttribute, groupName); + ldapObject.setDn(roleDn); + + for (Map.Entry> attrEntry : additionalAttributes.entrySet()) { + ldapObject.setAttribute(attrEntry.getKey(), attrEntry.getValue()); + } + + ldapProvider.getLdapIdentityStore().add(ldapObject); + return ldapObject; + } + + /** + * Add ldapChild as member of ldapParent and save ldapParent to LDAP. + * + * @param ldapProvider + * @param membershipType how is 'member' attribute saved (full DN or just uid) + * @param memberAttrName usually 'member' + * @param ldapParent role or group + * @param ldapChild usually user (or child group or child role) + * @param sendLDAPUpdateRequest if true, the method will send LDAP update request too. Otherwise it will skip it + */ + public static void addMember(LDAPStorageProvider ldapProvider, MembershipType membershipType, String memberAttrName, LDAPObject ldapParent, LDAPObject ldapChild, boolean sendLDAPUpdateRequest) { + + Set memberships = getExistingMemberships(memberAttrName, ldapParent); + + // Remove membership placeholder if present + if (membershipType == MembershipType.DN) { + for (String membership : memberships) { + if (LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE.equals(membership)) { + memberships.remove(membership); + break; + } + } + } + + String membership = getMemberValueOfChildObject(ldapChild, membershipType); + + memberships.add(membership); + ldapParent.setAttribute(memberAttrName, memberships); + + if (sendLDAPUpdateRequest) { + ldapProvider.getLdapIdentityStore().update(ldapParent); + } + } + + /** + * Remove ldapChild as member of ldapParent and save ldapParent to LDAP. + * + * @param ldapProvider + * @param membershipType how is 'member' attribute saved (full DN or just uid) + * @param memberAttrName usually 'member' + * @param ldapParent role or group + * @param ldapChild usually user (or child group or child role) + * @param sendLDAPUpdateRequest if true, the method will send LDAP update request too. Otherwise it will skip it + */ + public static void deleteMember(LDAPStorageProvider ldapProvider, MembershipType membershipType, String memberAttrName, LDAPObject ldapParent, LDAPObject ldapChild, boolean sendLDAPUpdateRequest) { + Set memberships = getExistingMemberships(memberAttrName, ldapParent); + + String userMembership = getMemberValueOfChildObject(ldapChild, membershipType); + + memberships.remove(userMembership); + + // Some membership placeholder needs to be always here as "member" is mandatory attribute on some LDAP servers. But not on active directory! (Placeholder, which not matches any real object is not allowed here) + if (memberships.size() == 0 && membershipType== MembershipType.DN && !ldapProvider.getLdapIdentityStore().getConfig().isActiveDirectory()) { + memberships.add(LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE); + } + + ldapParent.setAttribute(memberAttrName, memberships); + ldapProvider.getLdapIdentityStore().update(ldapParent); + } + + /** + * Return all existing memberships (values of attribute 'member' ) from the given ldapRole or ldapGroup + * + * @param memberAttrName usually 'member' + * @param ldapRole + * @return + */ + public static Set getExistingMemberships(String memberAttrName, LDAPObject ldapRole) { + Set memberships = ldapRole.getAttributeAsSet(memberAttrName); + if (memberships == null) { + memberships = new HashSet<>(); + } + return memberships; + } + + /** + * Get value to be used as attribute 'member' in some parent ldapObject + */ + public static String getMemberValueOfChildObject(LDAPObject ldapUser, MembershipType membershipType) { + return membershipType == MembershipType.DN ? ldapUser.getDn().toString() : ldapUser.getAttributeAsString(ldapUser.getRdnAttributeName()); + } + + + /** + * Load all LDAP objects corresponding to given query. We will load them paginated, so we allow to bypass the limitation of 1000 + * maximum loaded objects in single query in MSAD + * + * @param ldapQuery + * @param ldapProvider + * @return + */ + public static List loadAllLDAPObjects(LDAPQuery ldapQuery, LDAPStorageProvider ldapProvider) { + LDAPConfig ldapConfig = ldapProvider.getLdapIdentityStore().getConfig(); + boolean pagination = ldapConfig.isPagination(); + if (pagination) { + // For now reuse globally configured batch size in LDAP provider page + int pageSize = ldapConfig.getBatchSizeForSync(); + + List result = new LinkedList<>(); + boolean nextPage = true; + + while (nextPage) { + ldapQuery.setLimit(pageSize); + final List currentPageGroups = ldapQuery.getResultList(); + result.addAll(currentPageGroups); + nextPage = ldapQuery.getPaginationContext() != null; + } + + return result; + } else { + // LDAP pagination not available. Do everything in single transaction + return ldapQuery.getResultList(); + } + } + + + /** + * Validate configured customFilter matches the requested format + * + * @param customFilter + * @throws FederationConfigValidationException + */ + public static void validateCustomLdapFilter(String customFilter) throws ComponentValidationException { + if (customFilter != null) { + + customFilter = customFilter.trim(); + if (customFilter.isEmpty()) { + return; + } + + if (!customFilter.startsWith("(") || !customFilter.endsWith(")")) { + throw new ComponentValidationException("ldapErrorInvalidCustomFilter"); + } + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/ReadonlyLDAPUserModelDelegate.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/ReadonlyLDAPUserModelDelegate.java new file mode 100755 index 0000000000..18ed8e2d69 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/ReadonlyLDAPUserModelDelegate.java @@ -0,0 +1,57 @@ +/* + * 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.storage.ldap; + +import org.keycloak.models.ModelReadOnlyException; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.UserModelDelegate; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class ReadonlyLDAPUserModelDelegate extends UserModelDelegate implements UserModel { + + protected LDAPStorageProvider provider; + + public ReadonlyLDAPUserModelDelegate(UserModel delegate, LDAPStorageProvider provider) { + super(delegate); + this.provider = provider; + } + + @Override + public void setUsername(String username) { + throw new ModelReadOnlyException("Federated storage is not writable"); + } + + @Override + public void setLastName(String lastName) { + throw new ModelReadOnlyException("Federated storage is not writable"); + } + + @Override + public void setFirstName(String first) { + throw new ModelReadOnlyException("Federated storage is not writable"); + } + + @Override + public void setEmail(String email) { + throw new ModelReadOnlyException("Federated storage is not writable"); + } + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/UnsyncedLDAPUserModelDelegate.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/UnsyncedLDAPUserModelDelegate.java new file mode 100755 index 0000000000..e26104c823 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/UnsyncedLDAPUserModelDelegate.java @@ -0,0 +1,37 @@ +/* + * 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.storage.ldap; + +import org.jboss.logging.Logger; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.UserModelDelegate; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class UnsyncedLDAPUserModelDelegate extends UserModelDelegate implements UserModel { + private static final Logger logger = Logger.getLogger(UnsyncedLDAPUserModelDelegate.class); + + protected LDAPStorageProvider provider; + + public UnsyncedLDAPUserModelDelegate(UserModel delegate, LDAPStorageProvider provider) { + super(delegate); + this.provider = provider; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/WritableLDAPUserModelDelegate.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/WritableLDAPUserModelDelegate.java new file mode 100755 index 0000000000..6b87bb80c3 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/WritableLDAPUserModelDelegate.java @@ -0,0 +1,41 @@ +/* + * 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.storage.ldap; + +import org.jboss.logging.Logger; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.storage.ldap.idm.model.LDAPObject; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class WritableLDAPUserModelDelegate extends UserModelDelegate implements UserModel { + private static final Logger logger = Logger.getLogger(WritableLDAPUserModelDelegate.class); + + protected LDAPStorageProvider provider; + protected LDAPObject ldapObject; + + public WritableLDAPUserModelDelegate(UserModel delegate, LDAPStorageProvider provider, LDAPObject ldapObject) { + super(delegate); + this.provider = provider; + this.ldapObject = ldapObject; + } + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/model/LDAPDn.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/model/LDAPDn.java new file mode 100644 index 0000000000..e95e8adafd --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/model/LDAPDn.java @@ -0,0 +1,153 @@ +/* + * 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.storage.ldap.idm.model; + +import javax.naming.ldap.Rdn; +import java.util.Collection; +import java.util.Deque; +import java.util.LinkedList; + +/** + * @author Marek Posolda + */ +public class LDAPDn { + + private final Deque entries = new LinkedList<>(); + + public static LDAPDn fromString(String dnString) { + LDAPDn dn = new LDAPDn(); + + // In certain OpenLDAP implementations the uniqueMember attribute is mandatory + // Thus, if a new group is created, it will contain an empty uniqueMember attribute + // Later on, when adding members, this empty attribute will be kept + // Keycloak must be able to process it, properly, w/o throwing an ArrayIndexOutOfBoundsException + if(dnString.trim().isEmpty()) + return dn; + + String[] rdns = dnString.split("(? entries) { + StringBuilder builder = new StringBuilder(); + + boolean first = true; + for (Entry rdn : entries) { + if (first) { + first = false; + } else { + builder.append(","); + } + builder.append(rdn.attrName).append("=").append(rdn.attrValue); + } + + return builder.toString(); + } + + /** + * @return string like "uid=joe" from the DN like "uid=joe,dc=something,dc=org" + */ + public String getFirstRdn() { + Entry firstEntry = entries.getFirst(); + return firstEntry.attrName + "=" + firstEntry.attrValue; + } + + /** + * @return string attribute name like "uid" from the DN like "uid=joe,dc=something,dc=org" + */ + public String getFirstRdnAttrName() { + Entry firstEntry = entries.getFirst(); + return firstEntry.attrName; + } + + /** + * @return string attribute value like "joe" from the DN like "uid=joe,dc=something,dc=org" + */ + public String getFirstRdnAttrValue() { + Entry firstEntry = entries.getFirst(); + return firstEntry.attrValue; + } + + /** + * + * @return string like "dc=something,dc=org" from the DN like "uid=joe,dc=something,dc=org" + */ + public String getParentDn() { + LinkedList parentDnEntries = new LinkedList<>(entries); + parentDnEntries.remove(); + return toString(parentDnEntries); + } + + public boolean isDescendantOf(LDAPDn expectedParentDn) { + int parentEntriesCount = expectedParentDn.entries.size(); + + Deque myEntries = new LinkedList<>(this.entries); + boolean someRemoved = false; + while (myEntries.size() > parentEntriesCount) { + myEntries.removeFirst(); + someRemoved = true; + } + + String myEntriesParentStr = toString(myEntries).toLowerCase(); + String expectedParentDnStr = expectedParentDn.toString().toLowerCase(); + return someRemoved && myEntriesParentStr.equals(expectedParentDnStr); + } + + public void addFirst(String rdnName, String rdnValue) { + rdnValue = Rdn.escapeValue(rdnValue); + entries.addFirst(new Entry(rdnName, rdnValue)); + } + + private void addLast(String rdnName, String rdnValue) { + entries.addLast(new Entry(rdnName, rdnValue)); + } + + private static class Entry { + private final String attrName; + private final String attrValue; + + private Entry(String attrName, String attrValue) { + this.attrName = attrName; + this.attrValue = attrValue; + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/model/LDAPObject.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/model/LDAPObject.java new file mode 100644 index 0000000000..64ef65fd07 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/model/LDAPObject.java @@ -0,0 +1,157 @@ +/* + * 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.storage.ldap.idm.model; + +import org.jboss.logging.Logger; + +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public class LDAPObject { + + private static final Logger logger = Logger.getLogger(LDAPObject.class); + + private String uuid; + private LDAPDn dn; + private String rdnAttributeName; + + private final List objectClasses = new LinkedList<>(); + + // NOTE: names of read-only attributes are lower-cased to avoid case sensitivity issues + private final List readOnlyAttributeNames = new LinkedList<>(); + + private final Map> attributes = new HashMap<>(); + + // Copy of "attributes" containing lower-cased keys + private final Map> lowerCasedAttributes = new HashMap<>(); + + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public LDAPDn getDn() { + return dn; + } + + public void setDn(LDAPDn dn) { + this.dn = dn; + } + + public List getObjectClasses() { + return objectClasses; + } + + public void setObjectClasses(Collection objectClasses) { + this.objectClasses.clear(); + this.objectClasses.addAll(objectClasses); + } + + public List getReadOnlyAttributeNames() { + return readOnlyAttributeNames; + } + + public void addReadOnlyAttributeName(String readOnlyAttribute) { + readOnlyAttributeNames.add(readOnlyAttribute.toLowerCase()); + } + + public void removeReadOnlyAttributeName(String readOnlyAttribute) { + readOnlyAttributeNames.remove(readOnlyAttribute.toLowerCase()); + } + + public String getRdnAttributeName() { + return rdnAttributeName; + } + + public void setRdnAttributeName(String rdnAttributeName) { + this.rdnAttributeName = rdnAttributeName; + } + + public void setSingleAttribute(String attributeName, String attributeValue) { + Set asSet = new LinkedHashSet<>(); + asSet.add(attributeValue); + setAttribute(attributeName, asSet); + } + + public void setAttribute(String attributeName, Set attributeValue) { + attributes.put(attributeName, attributeValue); + lowerCasedAttributes.put(attributeName.toLowerCase(), attributeValue); + } + + // Case-insensitive + public String getAttributeAsString(String name) { + Set attrValue = lowerCasedAttributes.get(name.toLowerCase()); + if (attrValue == null || attrValue.size() == 0) { + return null; + } else if (attrValue.size() > 1) { + logger.warnf("Expected String but attribute '%s' has more values '%s' on object '%s' . Returning just first value", name, attrValue, dn); + } + + return attrValue.iterator().next(); + } + + // Case-insensitive. Return null if there is not value of attribute with given name or set with all values otherwise + public Set getAttributeAsSet(String name) { + Set values = lowerCasedAttributes.get(name.toLowerCase()); + return (values == null) ? null : new LinkedHashSet<>(values); + } + + + public Map> getAttributes() { + return attributes; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + + if (!getClass().isInstance(obj)) { + return false; + } + + LDAPObject other = (LDAPObject) obj; + + return getUuid() != null && other.getUuid() != null && getUuid().equals(other.getUuid()); + } + + @Override + public int hashCode() { + int result = getUuid() != null ? getUuid().hashCode() : 0; + result = 31 * result + (getUuid() != null ? getUuid().hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "LDAP Object [ dn: " + dn + " , uuid: " + uuid + ", attributes: " + attributes + ", readOnly attribute names: " + readOnlyAttributeNames + " ]"; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/Condition.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/Condition.java new file mode 100644 index 0000000000..152b0889ad --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/Condition.java @@ -0,0 +1,42 @@ +/* + * 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.storage.ldap.idm.query; + +/** + *

A {@link Condition} is used to specify how a specific query parameter + * is defined in order to filter query results.

+ * + * @author Pedro Igor + */ +public interface Condition { + + String getParameterName(); + void setParameterName(String parameterName); + + /** + * Will change the parameter name if it is "modelParamName" to "ldapParamName" . Implementation can apply this to subconditions as well. + * + * It is used to update LDAP queries, which were created with model parameter name ( for example "firstName" ) and rewrite them to use real + * LDAP mapped attribute (for example "givenName" ) + */ + void updateParameterName(String modelParamName, String ldapParamName); + + + void applyCondition(StringBuilder filter); + +} \ No newline at end of file diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/Sort.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/Sort.java new file mode 100644 index 0000000000..97e381d052 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/Sort.java @@ -0,0 +1,40 @@ +/* + * 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.storage.ldap.idm.query; + +/** + * @author Pedro Igor + */ +public class Sort { + + private final String paramName; + private final boolean asc; + + public Sort(String paramName, boolean asc) { + this.paramName = paramName; + this.asc = asc; + } + + public String getParameter() { + return this.paramName; + } + + public boolean isAscending() { + return asc; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/BetweenCondition.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/BetweenCondition.java new file mode 100644 index 0000000000..dedc29d71b --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/BetweenCondition.java @@ -0,0 +1,53 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.storage.ldap.idm.store.ldap.LDAPUtil; + +import java.util.Date; + +/** + * @author Pedro Igor + */ +class BetweenCondition extends NamedParameterCondition { + + private final Comparable x; + private final Comparable y; + + public BetweenCondition(String name, Comparable x, Comparable y) { + super(name); + this.x = x; + this.y = y; + } + + @Override + public void applyCondition(StringBuilder filter) { + Comparable x = this.x; + Comparable y = this.y; + + if (Date.class.isInstance(x)) { + x = LDAPUtil.formatDate((Date) x); + } + + if (Date.class.isInstance(y)) { + y = LDAPUtil.formatDate((Date) y); + } + + filter.append("(").append(x).append("<=").append(getParameterName()).append("<=").append(y).append(")"); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/CustomLDAPFilter.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/CustomLDAPFilter.java new file mode 100644 index 0000000000..c65a4754cb --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/CustomLDAPFilter.java @@ -0,0 +1,51 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.storage.ldap.idm.query.Condition; + +/** + * @author Marek Posolda + */ +class CustomLDAPFilter implements Condition { + + private final String customFilter; + + public CustomLDAPFilter(String customFilter) { + this.customFilter = customFilter; + } + + @Override + public String getParameterName() { + return null; + } + + @Override + public void setParameterName(String parameterName) { + } + + @Override + public void updateParameterName(String modelParamName, String ldapParamName) { + + } + + @Override + public void applyCondition(StringBuilder filter) { + filter.append(customFilter); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/EqualCondition.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/EqualCondition.java new file mode 100644 index 0000000000..e82fe376d2 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/EqualCondition.java @@ -0,0 +1,58 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.models.LDAPConstants; +import org.keycloak.storage.ldap.idm.store.ldap.LDAPUtil; + +import java.util.Date; + +/** + * @author Pedro Igor + */ +public class EqualCondition extends NamedParameterCondition { + + private final Object value; + + public EqualCondition(String name, Object value) { + super(name); + this.value = value; + } + + public Object getValue() { + return this.value; + } + + @Override + public void applyCondition(StringBuilder filter) { + Object parameterValue = value; + if (Date.class.isInstance(value)) { + parameterValue = LDAPUtil.formatDate((Date) parameterValue); + } + + filter.append("(").append(getParameterName()).append(LDAPConstants.EQUAL).append(parameterValue).append(")"); + } + + @Override + public String toString() { + return "EqualCondition{" + + "paramName=" + getParameterName() + + ", value=" + value + + '}'; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/GreaterThanCondition.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/GreaterThanCondition.java new file mode 100644 index 0000000000..32432e63ba --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/GreaterThanCondition.java @@ -0,0 +1,53 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.storage.ldap.idm.store.ldap.LDAPUtil; + +import java.util.Date; + +/** + * @author Pedro Igor + */ +class GreaterThanCondition extends NamedParameterCondition { + + private final boolean orEqual; + + private final Comparable value; + + public GreaterThanCondition(String name, Comparable value, boolean orEqual) { + super(name); + this.value = value; + this.orEqual = orEqual; + } + + @Override + public void applyCondition(StringBuilder filter) { + Comparable parameterValue = value; + + if (Date.class.isInstance(parameterValue)) { + parameterValue = LDAPUtil.formatDate((Date) parameterValue); + } + + if (orEqual) { + filter.append("(").append(getParameterName()).append(">=").append(parameterValue).append(")"); + } else { + filter.append("(").append(getParameterName()).append(">").append(parameterValue).append(")"); + } + } +} \ No newline at end of file diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/InCondition.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/InCondition.java new file mode 100644 index 0000000000..8f5c26a0e5 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/InCondition.java @@ -0,0 +1,48 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.models.LDAPConstants; + +/** + * @author Pedro Igor + */ +class InCondition extends NamedParameterCondition { + + private final Object[] valuesToCompare; + + public InCondition(String name, Object[] valuesToCompare) { + super(name); + this.valuesToCompare = valuesToCompare; + } + + @Override + public void applyCondition(StringBuilder filter) { + + filter.append("(&("); + + for (int i = 0; i< valuesToCompare.length; i++) { + Object value = valuesToCompare[i]; + + filter.append("(").append(getParameterName()).append(LDAPConstants.EQUAL).append(value).append(")"); + } + + filter.append("))"); + } +} + diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQuery.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQuery.java new file mode 100644 index 0000000000..eb7ff1bb9a --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQuery.java @@ -0,0 +1,213 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.ModelException; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.Condition; +import org.keycloak.storage.ldap.idm.query.Sort; +import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; + +import javax.naming.directory.SearchControls; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static java.util.Collections.unmodifiableSet; + +/** + * Default IdentityQuery implementation. + * + * + * @author Shane Bryzak + */ +public class LDAPQuery { + + private final LDAPStorageProvider ldapFedProvider; + + private int offset; + private int limit; + private byte[] paginationContext; + private String searchDn; + private final Set conditions = new LinkedHashSet(); + private final Set ordering = new LinkedHashSet(); + + private final Set returningLdapAttributes = new LinkedHashSet(); + + // Contains just those returningLdapAttributes, which are read-only. They will be marked as read-only in returned LDAPObject instances as well + // NOTE: names of attributes are lower-cased to avoid case sensitivity issues (LDAP searching is usually case-insensitive, so we want to be as well) + private final Set returningReadOnlyLdapAttributes = new LinkedHashSet(); + private final Set objectClasses = new LinkedHashSet(); + + private final List mappers = new ArrayList<>(); + + private int searchScope = SearchControls.SUBTREE_SCOPE; + + public LDAPQuery(LDAPStorageProvider ldapProvider) { + this.ldapFedProvider = ldapProvider; + } + + public LDAPQuery addWhereCondition(Condition... condition) { + this.conditions.addAll(Arrays.asList(condition)); + return this; + } + + public LDAPQuery sortBy(Sort... sorts) { + this.ordering.addAll(Arrays.asList(sorts)); + return this; + } + + public LDAPQuery setSearchDn(String searchDn) { + this.searchDn = searchDn; + return this; + } + + public LDAPQuery addObjectClasses(Collection objectClasses) { + this.objectClasses.addAll(objectClasses); + return this; + } + + public LDAPQuery addReturningLdapAttribute(String ldapAttributeName) { + this.returningLdapAttributes.add(ldapAttributeName); + return this; + } + + public LDAPQuery addReturningReadOnlyLdapAttribute(String ldapAttributeName) { + this.returningReadOnlyLdapAttributes.add(ldapAttributeName.toLowerCase()); + return this; + } + + public LDAPQuery addMappers(Collection mappers) { + this.mappers.addAll(mappers); + return this; + } + + public LDAPQuery setSearchScope(int searchScope) { + this.searchScope = searchScope; + return this; + } + + public Set getSorting() { + return unmodifiableSet(this.ordering); + } + + public String getSearchDn() { + return this.searchDn; + } + + public Set getObjectClasses() { + return unmodifiableSet(this.objectClasses); + } + + public Set getReturningLdapAttributes() { + return unmodifiableSet(this.returningLdapAttributes); + } + + public Set getReturningReadOnlyLdapAttributes() { + return unmodifiableSet(this.returningReadOnlyLdapAttributes); + } + + public List getMappers() { + return mappers; + } + + public int getSearchScope() { + return searchScope; + } + + public int getLimit() { + return limit; + } + + public int getOffset() { + return offset; + } + + public byte[] getPaginationContext() { + return paginationContext; + } + + + public List getResultList() { + + // Apply mappers now + List sortedMappers = ldapFedProvider.sortMappersAsc(mappers); + for (ComponentModel mapperModel : sortedMappers) { + LDAPStorageMapper fedMapper = ldapFedProvider.getMapper(mapperModel); + fedMapper.beforeLDAPQuery(mapperModel, this); + } + + List result = new ArrayList(); + + try { + for (LDAPObject ldapObject : ldapFedProvider.getLdapIdentityStore().fetchQueryResults(this)) { + result.add(ldapObject); + } + } catch (Exception e) { + throw new ModelException("LDAP Query failed", e); + } + + return result; + } + + public LDAPObject getFirstResult() { + List results = getResultList(); + + if (results.isEmpty()) { + return null; + } else if (results.size() == 1) { + return results.get(0); + } else { + throw new ModelDuplicateException("Error - multiple LDAP objects found but expected just one"); + } + } + + public int getResultCount() { + return ldapFedProvider.getLdapIdentityStore().countQueryResults(this); + } + + public LDAPQuery setOffset(int offset) { + this.offset = offset; + return this; + } + + public LDAPQuery setLimit(int limit) { + this.limit = limit; + return this; + } + + public LDAPQuery setPaginationContext(byte[] paginationContext) { + this.paginationContext = paginationContext; + return this; + } + + public Set getConditions() { + return this.conditions; + } + + public LDAPStorageProvider getLdapProvider() { + return ldapFedProvider; + } + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQueryConditionsBuilder.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQueryConditionsBuilder.java new file mode 100644 index 0000000000..715ec3da36 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQueryConditionsBuilder.java @@ -0,0 +1,84 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.models.ModelException; +import org.keycloak.storage.ldap.idm.query.Condition; +import org.keycloak.storage.ldap.idm.query.Sort; + +/** + * @author Pedro Igor + */ +public class LDAPQueryConditionsBuilder { + + public Condition equal(String parameter, Object value) { + return new EqualCondition(parameter, value); + } + + public Condition greaterThan(String paramName, Object x) { + throwExceptionIfNotComparable(x); + return new GreaterThanCondition(paramName, (Comparable) x, false); + } + + public Condition greaterThanOrEqualTo(String paramName, Object x) { + throwExceptionIfNotComparable(x); + return new GreaterThanCondition(paramName, (Comparable) x, true); + } + + public Condition lessThan(String paramName, Comparable x) { + return new LessThanCondition(paramName, x, false); + } + + public Condition lessThanOrEqualTo(String paramName, Comparable x) { + return new LessThanCondition(paramName, x, true); + } + + public Condition between(String paramName, Comparable x, Comparable y) { + return new BetweenCondition(paramName, x, y); + } + + public Condition orCondition(Condition... conditions) { + if (conditions == null || conditions.length == 0) { + throw new ModelException("At least one condition should be provided to OR query"); + } + return new OrCondition(conditions); + } + + public Condition addCustomLDAPFilter(String filter) { + filter = filter.trim(); + return new CustomLDAPFilter(filter); + } + + public Condition in(String paramName, Object... x) { + return new InCondition(paramName, x); + } + + public Sort asc(String paramName) { + return new Sort(paramName, true); + } + + public Sort desc(String paramName) { + return new Sort(paramName, false); + } + + private void throwExceptionIfNotComparable(Object x) { + if (!Comparable.class.isInstance(x)) { + throw new ModelException("Query parameter value [" + x + "] must be " + Comparable.class + "."); + } + } +} \ No newline at end of file diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LessThanCondition.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LessThanCondition.java new file mode 100644 index 0000000000..a32fb27867 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LessThanCondition.java @@ -0,0 +1,53 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.storage.ldap.idm.store.ldap.LDAPUtil; + +import java.util.Date; + +/** + * @author Pedro Igor + */ +class LessThanCondition extends NamedParameterCondition { + + private final boolean orEqual; + + private final Comparable value; + + public LessThanCondition(String name, Comparable value, boolean orEqual) { + super(name); + this.value = value; + this.orEqual = orEqual; + } + + @Override + public void applyCondition(StringBuilder filter) { + Comparable parameterValue = value; + + if (Date.class.isInstance(parameterValue)) { + parameterValue = LDAPUtil.formatDate((Date) parameterValue); + } + + if (orEqual) { + filter.append("(").append(getParameterName()).append("<=").append(parameterValue).append(")"); + } else { + filter.append("(").append(getParameterName()).append("<").append(parameterValue).append(")"); + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/NamedParameterCondition.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/NamedParameterCondition.java new file mode 100644 index 0000000000..72a9a0cb81 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/NamedParameterCondition.java @@ -0,0 +1,50 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.storage.ldap.idm.query.Condition; + +/** + * @author Marek Posolda + */ +public abstract class NamedParameterCondition implements Condition { + + private String parameterName; + + public NamedParameterCondition(String parameterName) { + this.parameterName = parameterName; + } + + @Override + public String getParameterName() { + return parameterName; + } + + @Override + public void setParameterName(String parameterName) { + this.parameterName = parameterName; + } + + + @Override + public void updateParameterName(String modelParamName, String ldapParamName) { + if (parameterName.equalsIgnoreCase(modelParamName)) { + this.parameterName = ldapParamName; + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/OrCondition.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/OrCondition.java new file mode 100644 index 0000000000..f605f9a97a --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/query/internal/OrCondition.java @@ -0,0 +1,59 @@ +/* + * 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.storage.ldap.idm.query.internal; + +import org.keycloak.storage.ldap.idm.query.Condition; + +/** + * @author Marek Posolda + */ +class OrCondition implements Condition { + + private final Condition[] innerConditions; + + public OrCondition(Condition... innerConditions) { + this.innerConditions = innerConditions; + } + + @Override + public String getParameterName() { + return null; + } + + @Override + public void setParameterName(String parameterName) { + } + + @Override + public void updateParameterName(String modelParamName, String ldapParamName) { + for (Condition innerCondition : innerConditions) { + innerCondition.updateParameterName(modelParamName, ldapParamName); + } + } + + @Override + public void applyCondition(StringBuilder filter) { + filter.append("(|"); + + for (Condition innerCondition : innerConditions) { + innerCondition.applyCondition(filter); + } + + filter.append(")"); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/IdentityStore.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/IdentityStore.java new file mode 100644 index 0000000000..4b2010b072 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/IdentityStore.java @@ -0,0 +1,98 @@ +/* + * 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.storage.ldap.idm.store; + +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; + +import javax.naming.AuthenticationException; +import java.util.List; + +/** + * IdentityStore representation providing minimal SPI + * + * TODO: Rather remove this abstraction + * + * @author Boleslaw Dawidowicz + * @author Shane Bryzak + */ +public interface IdentityStore { + + /** + * Returns the configuration for this IdentityStore instance + * + * @return + */ + LDAPConfig getConfig(); + + // General + + /** + * Persists the specified IdentityType + * + * @param ldapObject + */ + void add(LDAPObject ldapObject); + + /** + * Updates the specified IdentityType + * + * @param ldapObject + */ + void update(LDAPObject ldapObject); + + /** + * Removes the specified IdentityType + * + * @param ldapObject + */ + void remove(LDAPObject ldapObject); + + // Identity query + + List fetchQueryResults(LDAPQuery LDAPQuery); + + int countQueryResults(LDAPQuery LDAPQuery); + +// // Relationship query +// +// List fetchQueryResults(RelationshipQuery query); +// +// int countQueryResults(RelationshipQuery query); + + // Credentials + + /** + * Validates the specified credentials. + * + * @param user Keycloak user + * @param password Ldap password + * @throws AuthenticationException if authentication is not successful + */ + void validatePassword(LDAPObject user, String password) throws AuthenticationException; + + /** + * Updates the specified credential value. + * + * @param user Keycloak user + * @param password Ldap password + */ + void updatePassword(LDAPObject user, String password); + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPIdentityStore.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPIdentityStore.java new file mode 100644 index 0000000000..6d0e2cc0b3 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPIdentityStore.java @@ -0,0 +1,423 @@ +/* + * 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.storage.ldap.idm.store.ldap; + +import org.jboss.logging.Logger; +import org.keycloak.models.LDAPConstants; +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.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.Condition; +import org.keycloak.storage.ldap.idm.query.internal.EqualCondition; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; +import org.keycloak.storage.ldap.idm.store.IdentityStore; + +import javax.naming.AuthenticationException; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.BasicAttribute; +import javax.naming.directory.BasicAttributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.ModificationItem; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.TreeSet; + +/** + * An IdentityStore implementation backed by an LDAP directory + * + * @author Shane Bryzak + * @author Anil Saldhana + * @author Pedro Silva + */ +public class LDAPIdentityStore implements IdentityStore { + + private static final Logger logger = Logger.getLogger(LDAPIdentityStore.class); + + private final LDAPConfig config; + private final LDAPOperationManager operationManager; + + public LDAPIdentityStore(LDAPConfig config) { + this.config = config; + + try { + this.operationManager = new LDAPOperationManager(config); + } catch (NamingException e) { + throw new ModelException("Couldn't init operation manager", e); + } + } + + @Override + public LDAPConfig getConfig() { + return this.config; + } + + @Override + public void add(LDAPObject ldapObject) { + // id will be assigned by the ldap server + if (ldapObject.getUuid() != null) { + throw new ModelException("Can't add object with already assigned uuid"); + } + + String entryDN = ldapObject.getDn().toString(); + BasicAttributes ldapAttributes = extractAttributes(ldapObject, true); + this.operationManager.createSubContext(entryDN, ldapAttributes); + ldapObject.setUuid(getEntryIdentifier(ldapObject)); + + if (logger.isDebugEnabled()) { + logger.debugf("Type with identifier [%s] and dn [%s] successfully added to LDAP store.", ldapObject.getUuid(), entryDN); + } + } + + @Override + public void update(LDAPObject ldapObject) { + BasicAttributes updatedAttributes = extractAttributes(ldapObject, false); + NamingEnumeration attributes = updatedAttributes.getAll(); + + String entryDn = ldapObject.getDn().toString(); + this.operationManager.modifyAttributes(entryDn, attributes); + + if (logger.isDebugEnabled()) { + logger.debugf("Type with identifier [%s] and DN [%s] successfully updated to LDAP store.", ldapObject.getUuid(), entryDn); + } + } + + @Override + public void remove(LDAPObject ldapObject) { + this.operationManager.removeEntry(ldapObject.getDn().toString()); + + if (logger.isDebugEnabled()) { + logger.debugf("Type with identifier [%s] and DN [%s] successfully removed from LDAP store.", ldapObject.getUuid(), ldapObject.getDn().toString()); + } + } + + + @Override + public List fetchQueryResults(LDAPQuery identityQuery) { + if (identityQuery.getSorting() != null && !identityQuery.getSorting().isEmpty()) { + throw new ModelException("LDAP Identity Store does not yet support sorted queries."); + } + + List results = new ArrayList<>(); + + try { + String baseDN = identityQuery.getSearchDn(); + + for (Condition condition : identityQuery.getConditions()) { + + // Check if we are searching by ID + String uuidAttrName = getConfig().getUuidLDAPAttributeName(); + if (condition instanceof EqualCondition) { + EqualCondition equalCondition = (EqualCondition) condition; + if (equalCondition.getParameterName().equalsIgnoreCase(uuidAttrName)) { + SearchResult search = this.operationManager + .lookupById(baseDN, equalCondition.getValue().toString(), identityQuery.getReturningLdapAttributes()); + + if (search != null) { + results.add(populateAttributedType(search, identityQuery)); + } + + return results; + } + } + } + + + StringBuilder filter = createIdentityTypeSearchFilter(identityQuery); + + List search; + if (getConfig().isPagination() && identityQuery.getLimit() > 0) { + search = this.operationManager.searchPaginated(baseDN, filter.toString(), identityQuery); + } else { + search = this.operationManager.search(baseDN, filter.toString(), identityQuery.getReturningLdapAttributes(), identityQuery.getSearchScope()); + } + + for (SearchResult result : search) { + if (!result.getNameInNamespace().equalsIgnoreCase(baseDN)) { + results.add(populateAttributedType(result, identityQuery)); + } + } + } catch (Exception e) { + throw new ModelException("Querying of LDAP failed " + identityQuery, e); + } + + return results; + } + + @Override + public int countQueryResults(LDAPQuery identityQuery) { + int limit = identityQuery.getLimit(); + int offset = identityQuery.getOffset(); + + identityQuery.setLimit(0); + identityQuery.setOffset(0); + + int resultCount = identityQuery.getResultList().size(); + + identityQuery.setLimit(limit); + identityQuery.setOffset(offset); + + return resultCount; + } + + // *************** CREDENTIALS AND USER SPECIFIC STUFF + + @Override + public void validatePassword(LDAPObject user, String password) throws AuthenticationException { + String userDN = user.getDn().toString(); + + if (logger.isTraceEnabled()) { + logger.tracef("Using DN [%s] for authentication of user", userDN); + } + + operationManager.authenticate(userDN, password); + } + + @Override + public void updatePassword(LDAPObject user, String password) { + String userDN = user.getDn().toString(); + + if (logger.isDebugEnabled()) { + logger.debugf("Using DN [%s] for updating LDAP password of user", userDN); + } + + if (getConfig().isActiveDirectory()) { + updateADPassword(userDN, password); + } else { + ModificationItem[] mods = new ModificationItem[1]; + + try { + BasicAttribute mod0 = new BasicAttribute(LDAPConstants.USER_PASSWORD_ATTRIBUTE, password); + + mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, mod0); + + operationManager.modifyAttribute(userDN, mod0); + } catch (ModelException me) { + throw me; + } catch (Exception e) { + throw new ModelException("Error updating password.", e); + } + } + } + + + private void updateADPassword(String userDN, String password) { + try { + // Replace the "unicdodePwd" attribute with a new value + // Password must be both Unicode and a quoted string + String newQuotedPassword = "\"" + password + "\""; + byte[] newUnicodePassword = newQuotedPassword.getBytes("UTF-16LE"); + + BasicAttribute unicodePwd = new BasicAttribute("unicodePwd", newUnicodePassword); + + List modItems = new ArrayList(); + modItems.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE, unicodePwd)); + + operationManager.modifyAttributes(userDN, modItems.toArray(new ModificationItem[] {})); + } catch (ModelException me) { + throw me; + } catch (Exception e) { + throw new ModelException(e); + } + } + + // ************ END CREDENTIALS AND USER SPECIFIC STUFF + + protected StringBuilder createIdentityTypeSearchFilter(final LDAPQuery identityQuery) { + StringBuilder filter = new StringBuilder(); + + for (Condition condition : identityQuery.getConditions()) { + condition.applyCondition(filter); + } + + filter.insert(0, "(&"); + filter.append(getObjectClassesFilter(identityQuery.getObjectClasses())); + filter.append(")"); + + if (logger.isTraceEnabled()) { + logger.tracef("Using filter for LDAP search: %s . Searching in DN: %s", filter, identityQuery.getSearchDn()); + } + return filter; + } + + + private StringBuilder getObjectClassesFilter(Collection objectClasses) { + StringBuilder builder = new StringBuilder(); + + if (!objectClasses.isEmpty()) { + for (String objectClass : objectClasses) { + builder.append("(").append(LDAPConstants.OBJECT_CLASS).append(LDAPConstants.EQUAL).append(objectClass).append(")"); + } + } else { + builder.append("(").append(LDAPConstants.OBJECT_CLASS).append(LDAPConstants.EQUAL).append("*").append(")"); + } + + return builder; + } + + + private LDAPObject populateAttributedType(SearchResult searchResult, LDAPQuery ldapQuery) { + Set readOnlyAttrNames = ldapQuery.getReturningReadOnlyLdapAttributes(); + Set lowerCasedAttrNames = new TreeSet<>(); + for (String attrName : ldapQuery.getReturningLdapAttributes()) { + lowerCasedAttrNames.add(attrName.toLowerCase()); + } + + try { + String entryDN = searchResult.getNameInNamespace(); + Attributes attributes = searchResult.getAttributes(); + + LDAPObject ldapObject = new LDAPObject(); + LDAPDn dn = LDAPDn.fromString(entryDN); + ldapObject.setDn(dn); + ldapObject.setRdnAttributeName(dn.getFirstRdnAttrName()); + + NamingEnumeration ldapAttributes = attributes.getAll(); + + while (ldapAttributes.hasMore()) { + Attribute ldapAttribute = ldapAttributes.next(); + + try { + ldapAttribute.get(); + } catch (NoSuchElementException nsee) { + continue; + } + + String ldapAttributeName = ldapAttribute.getID(); + + if (ldapAttributeName.equalsIgnoreCase(getConfig().getUuidLDAPAttributeName())) { + Object uuidValue = ldapAttribute.get(); + ldapObject.setUuid(this.operationManager.decodeEntryUUID(uuidValue)); + } + + // Note: UUID is normally not populated here. It's populated just in case that it's used for name of other attribute as well + if (!ldapAttributeName.equalsIgnoreCase(getConfig().getUuidLDAPAttributeName()) || (lowerCasedAttrNames.contains(ldapAttributeName.toLowerCase()))) { + Set attrValues = new LinkedHashSet<>(); + NamingEnumeration enumm = ldapAttribute.getAll(); + while (enumm.hasMoreElements()) { + String attrVal = enumm.next().toString().trim(); + attrValues.add(attrVal); + } + + if (ldapAttributeName.equalsIgnoreCase(LDAPConstants.OBJECT_CLASS)) { + ldapObject.setObjectClasses(attrValues); + } else { + ldapObject.setAttribute(ldapAttributeName, attrValues); + + // readOnlyAttrNames are lower-cased + if (readOnlyAttrNames.contains(ldapAttributeName.toLowerCase())) { + ldapObject.addReadOnlyAttributeName(ldapAttributeName); + } + } + } + } + + if (logger.isTraceEnabled()) { + logger.tracef("Found ldap object and populated with the attributes. LDAP Object: %s", ldapObject.toString()); + } + return ldapObject; + + } catch (Exception e) { + throw new ModelException("Could not populate attribute type " + searchResult.getNameInNamespace() + ".", e); + } + } + + + protected BasicAttributes extractAttributes(LDAPObject ldapObject, boolean isCreate) { + BasicAttributes entryAttributes = new BasicAttributes(); + + for (Map.Entry> attrEntry : ldapObject.getAttributes().entrySet()) { + String attrName = attrEntry.getKey(); + Set attrValue = attrEntry.getValue(); + + // ldapObject.getReadOnlyAttributeNames() are lower-cased + if (!ldapObject.getReadOnlyAttributeNames().contains(attrName.toLowerCase()) && (isCreate || !ldapObject.getRdnAttributeName().equalsIgnoreCase(attrName))) { + + if (attrValue == null) { + // Shouldn't happen + logger.warnf("Attribute '%s' is null on LDAP object '%s' . Using empty value to be saved to LDAP", attrName, ldapObject.getDn().toString()); + attrValue = Collections.emptySet(); + } + + // Ignore empty attributes during create + if (isCreate && attrValue.isEmpty()) { + continue; + } + + BasicAttribute attr = new BasicAttribute(attrName); + for (String val : attrValue) { + if (val == null || val.toString().trim().length() == 0) { + val = LDAPConstants.EMPTY_ATTRIBUTE_VALUE; + } + attr.add(val); + } + + entryAttributes.put(attr); + } + } + + // Don't extract object classes for update + if (isCreate) { + BasicAttribute objectClassAttribute = new BasicAttribute(LDAPConstants.OBJECT_CLASS); + + for (String objectClassValue : ldapObject.getObjectClasses()) { + objectClassAttribute.add(objectClassValue); + + if (objectClassValue.equalsIgnoreCase(LDAPConstants.GROUP_OF_NAMES) + || objectClassValue.equalsIgnoreCase(LDAPConstants.GROUP_OF_ENTRIES) + || objectClassValue.equalsIgnoreCase(LDAPConstants.GROUP_OF_UNIQUE_NAMES)) { + entryAttributes.put(LDAPConstants.MEMBER, LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE); + } + } + + entryAttributes.put(objectClassAttribute); + } + + return entryAttributes; + } + + + protected String getEntryIdentifier(final LDAPObject ldapObject) { + try { + // we need this to retrieve the entry's identifier from the ldap server + String uuidAttrName = getConfig().getUuidLDAPAttributeName(); + List search = this.operationManager.search(ldapObject.getDn().toString(), "(" + ldapObject.getDn().getFirstRdn() + ")", Arrays.asList(uuidAttrName), SearchControls.OBJECT_SCOPE); + Attribute id = search.get(0).getAttributes().get(getConfig().getUuidLDAPAttributeName()); + + if (id == null) { + throw new ModelException("Could not retrieve identifier for entry [" + ldapObject.getDn().toString() + "]."); + } + + return this.operationManager.decodeEntryUUID(id.get()); + } catch (NamingException ne) { + throw new ModelException("Could not retrieve identifier for entry [" + ldapObject.getDn().toString() + "]."); + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPOperationManager.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPOperationManager.java new file mode 100644 index 0000000000..4fe40020e3 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPOperationManager.java @@ -0,0 +1,562 @@ +/* + * 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.storage.ldap.idm.store.ldap; + +import org.jboss.logging.Logger; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; + +import javax.naming.AuthenticationException; +import javax.naming.Binding; +import javax.naming.Context; +import javax.naming.InitialContext; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.ModificationItem; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.Control; +import javax.naming.ldap.InitialLdapContext; +import javax.naming.ldap.LdapContext; +import javax.naming.ldap.PagedResultsControl; +import javax.naming.ldap.PagedResultsResponseControl; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +/** + *

This class provides a set of operations to manage LDAP trees.

+ * + * @author Anil Saldhana + * @author Pedro Silva + */ +public class LDAPOperationManager { + + private static final Logger logger = Logger.getLogger(LDAPOperationManager.class); + + private final LDAPConfig config; + private final Map connectionProperties; + + public LDAPOperationManager(LDAPConfig config) throws NamingException { + this.config = config; + this.connectionProperties = Collections.unmodifiableMap(createConnectionProperties()); + } + + /** + *

+ * Modifies the given {@link javax.naming.directory.Attribute} instance using the given DN. This method performs a REPLACE_ATTRIBUTE + * operation. + *

+ * + * @param dn + * @param attribute + */ + public void modifyAttribute(String dn, Attribute attribute) { + ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attribute)}; + modifyAttributes(dn, mods); + } + + /** + *

+ * Modifies the given {@link Attribute} instances using the given DN. This method performs a REPLACE_ATTRIBUTE + * operation. + *

+ * + * @param dn + * @param attributes + */ + public void modifyAttributes(String dn, NamingEnumeration attributes) { + try { + List modItems = new ArrayList(); + while (attributes.hasMore()) { + ModificationItem modItem = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attributes.next()); + modItems.add(modItem); + } + + modifyAttributes(dn, modItems.toArray(new ModificationItem[] {})); + } catch (NamingException ne) { + throw new ModelException("Could not modify attributes on entry from DN [" + dn + "]", ne); + } + + } + + /** + *

+ * Removes the given {@link Attribute} instance using the given DN. This method performs a REMOVE_ATTRIBUTE + * operation. + *

+ * + * @param dn + * @param attribute + */ + public void removeAttribute(String dn, Attribute attribute) { + ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.REMOVE_ATTRIBUTE, attribute)}; + modifyAttributes(dn, mods); + } + + /** + *

+ * Adds the given {@link Attribute} instance using the given DN. This method performs a ADD_ATTRIBUTE operation. + *

+ * + * @param dn + * @param attribute + */ + public void addAttribute(String dn, Attribute attribute) { + ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.ADD_ATTRIBUTE, attribute)}; + modifyAttributes(dn, mods); + } + + /** + *

+ * Removes the object from the LDAP tree + *

+ */ + public void removeEntry(final String entryDn) { + try { + execute(new LdapOperation() { + @Override + public SearchResult execute(LdapContext context) throws NamingException { + if (logger.isTraceEnabled()) { + logger.tracef("Removing entry with DN [%s]", entryDn); + } + destroySubcontext(context, entryDn); + return null; + } + }); + } catch (NamingException e) { + throw new ModelException("Could not remove entry from DN [" + entryDn + "]", e); + } + } + + public List search(final String baseDN, final String filter, Collection returningAttributes, int searchScope) throws NamingException { + final List result = new ArrayList(); + final SearchControls cons = getSearchControls(returningAttributes, searchScope); + + try { + return execute(new LdapOperation>() { + @Override + public List execute(LdapContext context) throws NamingException { + NamingEnumeration search = context.search(baseDN, filter, cons); + + while (search.hasMoreElements()) { + result.add(search.nextElement()); + } + + search.close(); + + return result; + } + }); + } catch (NamingException e) { + logger.errorf(e, "Could not query server using DN [%s] and filter [%s]", baseDN, filter); + throw e; + } + } + + public List searchPaginated(final String baseDN, final String filter, final LDAPQuery identityQuery) throws NamingException { + final List result = new ArrayList(); + final SearchControls cons = getSearchControls(identityQuery.getReturningLdapAttributes(), identityQuery.getSearchScope()); + + try { + return execute(new LdapOperation>() { + @Override + public List execute(LdapContext context) throws NamingException { + try { + byte[] cookie = identityQuery.getPaginationContext(); + PagedResultsControl pagedControls = new PagedResultsControl(identityQuery.getLimit(), cookie, Control.CRITICAL); + context.setRequestControls(new Control[] { pagedControls }); + + NamingEnumeration search = context.search(baseDN, filter, cons); + + while (search.hasMoreElements()) { + result.add(search.nextElement()); + } + + search.close(); + + Control[] responseControls = context.getResponseControls(); + if (responseControls != null) { + for (Control respControl : responseControls) { + if (respControl instanceof PagedResultsResponseControl) { + PagedResultsResponseControl prrc = (PagedResultsResponseControl)respControl; + cookie = prrc.getCookie(); + identityQuery.setPaginationContext(cookie); + } + } + } + + return result; + } catch (IOException ioe) { + logger.errorf(ioe, "Could not query server with paginated query using DN [%s], filter [%s]", baseDN, filter); + throw new NamingException(ioe.getMessage()); + } + } + }); + } catch (NamingException e) { + logger.errorf(e, "Could not query server using DN [%s] and filter [%s]", baseDN, filter); + throw e; + } + } + + private SearchControls getSearchControls(Collection returningAttributes, int searchScope) { + final SearchControls cons = new SearchControls(); + + cons.setSearchScope(searchScope); + cons.setReturningObjFlag(false); + + returningAttributes = getReturningAttributes(returningAttributes); + + cons.setReturningAttributes(returningAttributes.toArray(new String[returningAttributes.size()])); + return cons; + } + + public String getFilterById(String id) { + String filter = null; + + if (this.config.isActiveDirectory()) { + final String strObjectGUID = ""; + + try { + Attributes attributes = execute(new LdapOperation() { + @Override + public Attributes execute(LdapContext context) throws NamingException { + return context.getAttributes(strObjectGUID); + } + }); + + byte[] objectGUID = (byte[]) attributes.get(LDAPConstants.OBJECT_GUID).get(); + + filter = "(&(objectClass=*)(" + getUuidAttributeName() + LDAPConstants.EQUAL + LDAPUtil.convertObjectGUIToByteString(objectGUID) + "))"; + } catch (NamingException ne) { + filter = null; + } + } + + if (filter == null) { + filter = "(&(objectClass=*)(" + getUuidAttributeName() + LDAPConstants.EQUAL + id + "))"; + } + + return filter; + } + + public SearchResult lookupById(final String baseDN, final String id, final Collection returningAttributes) { + final String filter = getFilterById(id); + + try { + final SearchControls cons = getSearchControls(returningAttributes, this.config.getSearchScope()); + + return execute(new LdapOperation() { + @Override + public SearchResult execute(LdapContext context) throws NamingException { + NamingEnumeration search = context.search(baseDN, filter, cons); + + try { + if (search.hasMoreElements()) { + return search.next(); + } + } finally { + if (search != null) { + search.close(); + } + } + + return null; + } + }); + } catch (NamingException e) { + throw new ModelException("Could not query server using DN [" + baseDN + "] and filter [" + filter + "]", e); + } + } + + /** + *

+ * Destroys a subcontext with the given DN from the LDAP tree. + *

+ * + * @param dn + */ + private void destroySubcontext(LdapContext context, final String dn) { + try { + NamingEnumeration enumeration = null; + + try { + enumeration = context.listBindings(dn); + + while (enumeration.hasMore()) { + Binding binding = enumeration.next(); + String name = binding.getNameInNamespace(); + + destroySubcontext(context, name); + } + + context.unbind(dn); + } finally { + try { + enumeration.close(); + } catch (Exception e) { + } + } + } catch (Exception e) { + throw new ModelException("Could not unbind DN [" + dn + "]", e); + } + } + + /** + *

+ * Performs a simple authentication using the given DN and password to bind to the authentication context. + *

+ * + * @param dn + * @param password + * @throws AuthenticationException if authentication is not successful + * + */ + public void authenticate(String dn, String password) throws AuthenticationException { + InitialContext authCtx = null; + + try { + if (password == null || password.isEmpty()) { + throw new AuthenticationException("Empty password used"); + } + + Hashtable env = new Hashtable(this.connectionProperties); + + env.put(Context.SECURITY_AUTHENTICATION, LDAPConstants.AUTH_TYPE_SIMPLE); + env.put(Context.SECURITY_PRINCIPAL, dn); + env.put(Context.SECURITY_CREDENTIALS, password); + + // Never use connection pool to prevent password caching + env.put("com.sun.jndi.ldap.connect.pool", "false"); + + authCtx = new InitialLdapContext(env, null); + + } catch (AuthenticationException ae) { + if (logger.isDebugEnabled()) { + logger.debugf(ae, "Authentication failed for DN [%s]", dn); + } + + throw ae; + } catch (Exception e) { + logger.errorf(e, "Unexpected exception when validating password of DN [%s]", dn); + throw new AuthenticationException("Unexpected exception when validating password of user"); + } finally { + if (authCtx != null) { + try { + authCtx.close(); + } catch (NamingException e) { + + } + } + } + } + + public void modifyAttributes(final String dn, final ModificationItem[] mods) { + try { + if (logger.isTraceEnabled()) { + logger.tracef("Modifying attributes for entry [%s]: [", dn); + + for (ModificationItem item : mods) { + Object values; + + if (item.getAttribute().size() > 0) { + values = item.getAttribute().get(); + } else { + values = "No values"; + } + + logger.tracef(" Op [%s]: %s = %s", item.getModificationOp(), item.getAttribute().getID(), values); + } + + logger.tracef("]"); + } + + execute(new LdapOperation() { + @Override + public Void execute(LdapContext context) throws NamingException { + context.modifyAttributes(dn, mods); + return null; + } + }); + } catch (NamingException e) { + throw new ModelException("Could not modify attribute for DN [" + dn + "]", e); + } + } + + public void createSubContext(final String name, final Attributes attributes) { + try { + if (logger.isTraceEnabled()) { + logger.tracef("Creating entry [%s] with attributes: [", name); + + NamingEnumeration all = attributes.getAll(); + + while (all.hasMore()) { + Attribute attribute = all.next(); + + logger.tracef(" %s = %s", attribute.getID(), attribute.get()); + } + + logger.tracef("]"); + } + + execute(new LdapOperation() { + @Override + public Void execute(LdapContext context) throws NamingException { + DirContext subcontext = context.createSubcontext(name, attributes); + + subcontext.close(); + + return null; + } + }); + } catch (NamingException e) { + throw new ModelException("Error creating subcontext [" + name + "]", e); + } + } + + private String getUuidAttributeName() { + return this.config.getUuidLDAPAttributeName(); + } + + public Attributes getAttributes(final String entryUUID, final String baseDN, Set returningAttributes) { + SearchResult search = lookupById(baseDN, entryUUID, returningAttributes); + + if (search == null) { + throw new ModelException("Couldn't find item with ID [" + entryUUID + " under base DN [" + baseDN + "]"); + } + + return search.getAttributes(); + } + + public String decodeEntryUUID(final Object entryUUID) { + String id; + + if (this.config.isActiveDirectory() && entryUUID instanceof byte[]) { + id = LDAPUtil.decodeObjectGUID((byte[]) entryUUID); + } else { + id = entryUUID.toString(); + } + + return id; + } + + private LdapContext createLdapContext() throws NamingException { + return new InitialLdapContext(new Hashtable(this.connectionProperties), null); + } + + private Map createConnectionProperties() { + HashMap env = new HashMap(); + + String authType = this.config.getAuthType(); + env.put(Context.INITIAL_CONTEXT_FACTORY, this.config.getFactoryName()); + env.put(Context.SECURITY_AUTHENTICATION, authType); + + String bindDN = this.config.getBindDN(); + + char[] bindCredential = null; + + if (this.config.getBindCredential() != null) { + bindCredential = this.config.getBindCredential().toCharArray(); + } + + if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) { + env.put(Context.SECURITY_PRINCIPAL, bindDN); + env.put(Context.SECURITY_CREDENTIALS, bindCredential); + } + + String url = this.config.getConnectionUrl(); + + if (url != null) { + env.put(Context.PROVIDER_URL, url); + } else { + logger.warn("LDAP URL is null. LDAPOperationManager won't work correctly"); + } + + String useTruststoreSpi = this.config.getUseTruststoreSpi(); + LDAPConstants.setTruststoreSpiIfNeeded(useTruststoreSpi, url, env); + + String connectionPooling = this.config.getConnectionPooling(); + if (connectionPooling != null) { + env.put("com.sun.jndi.ldap.connect.pool", connectionPooling); + } + + // Just dump the additional properties + Properties additionalProperties = this.config.getAdditionalConnectionProperties(); + if (additionalProperties != null) { + for (Object key : additionalProperties.keySet()) { + env.put(key.toString(), additionalProperties.getProperty(key.toString())); + } + } + + if (config.isActiveDirectory()) { + env.put("java.naming.ldap.attributes.binary", LDAPConstants.OBJECT_GUID); + } + + if (logger.isDebugEnabled()) { + logger.debugf("Creating LdapContext using properties: [%s]", env); + } + + return env; + } + + private R execute(LdapOperation operation) throws NamingException { + LdapContext context = null; + + try { + context = createLdapContext(); + return operation.execute(context); + } catch (NamingException ne) { + throw ne; + } finally { + if (context != null) { + try { + context.close(); + } catch (NamingException ne) { + logger.error("Could not close Ldap context.", ne); + } + } + } + } + + private interface LdapOperation { + R execute(LdapContext context) throws NamingException; + } + + private Set getReturningAttributes(final Collection returningAttributes) { + Set result = new HashSet(); + + result.addAll(returningAttributes); + result.add(getUuidAttributeName()); + result.add(LDAPConstants.OBJECT_CLASS); + + return result; + } +} \ No newline at end of file diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPUtil.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPUtil.java new file mode 100644 index 0000000000..2dc9d99ccb --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/idm/store/ldap/LDAPUtil.java @@ -0,0 +1,175 @@ +/* + * 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.storage.ldap.idm.store.ldap; + +import org.keycloak.models.ModelException; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** + *

Utility class for working with LDAP.

+ * + * @author Pedro Igor + */ +public class LDAPUtil { + + /** + *

Formats the given date.

+ * + * @param date The Date to format. + * + * @return A String representing the formatted date. + */ + public static final String formatDate(Date date) { + if (date == null) { + throw new IllegalArgumentException("You must provide a date."); + } + + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss'.0Z'"); + + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + + return dateFormat.format(date); + } + + /** + *

+ * Parses dates/time stamps stored in LDAP. Some possible values: + *

+ *
    + *
  • 20020228150820
  • + *
  • 20030228150820Z
  • + *
  • 20050228150820.12
  • + *
  • 20060711011740.0Z
  • + *
+ * + * @param date The date string to parse from. + * + * @return the Date. + */ + public static final Date parseDate(String date) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss"); + + try { + if (date.endsWith("Z")) { + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } else { + dateFormat.setTimeZone(TimeZone.getDefault()); + } + + return dateFormat.parse(date); + } catch (Exception e) { + throw new ModelException("Error converting ldap date.", e); + } + } + + + + /** + *

Creates a byte-based {@link String} representation of a raw byte array representing the value of the + * objectGUID attribute retrieved from Active Directory.

+ * + *

The returned string is useful to perform queries on AD based on the objectGUID value. Eg.:

+ * + *

+ * String filter = "(&(objectClass=*)(objectGUID" + EQUAL + convertObjectGUIToByteString(objectGUID) + "))"; + *

+ * + * @param objectGUID A raw byte array representing the value of the objectGUID attribute retrieved from + * Active Directory. + * + * @return A byte-based String representation in the form of \[0]\[1]\[2]\[3]\[4]\[5]\[6]\[7]\[8]\[9]\[10]\[11]\[12]\[13]\[14]\[15] + */ + public static String convertObjectGUIToByteString(byte[] objectGUID) { + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < objectGUID.length; i++) { + String transformed = prefixZeros((int) objectGUID[i] & 0xFF); + result.append("\\"); + result.append(transformed); + } + + return result.toString(); + } + + /** + *

Decode a raw byte array representing the value of the objectGUID attribute retrieved from Active + * Directory.

+ * + *

The returned string is useful to directly bind an entry. Eg.:

+ * + *

+ * String bindingString = decodeObjectGUID(objectGUID); + *
+ * Attributes attributes = ctx.getAttributes(bindingString); + *

+ * + * @param objectGUID A raw byte array representing the value of the objectGUID attribute retrieved from + * Active Directory. + * + * @return A string representing the decoded value in the form of [3][2][1][0]-[5][4]-[7][6]-[8][9]-[10][11][12][13][14][15]. + */ + public static String decodeObjectGUID(byte[] objectGUID) { + StringBuilder displayStr = new StringBuilder(); + + displayStr.append(convertToDashedString(objectGUID)); + + return displayStr.toString(); + } + + private static String convertToDashedString(byte[] objectGUID) { + StringBuilder displayStr = new StringBuilder(); + + displayStr.append(prefixZeros((int) objectGUID[3] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[2] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[1] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[0] & 0xFF)); + displayStr.append("-"); + displayStr.append(prefixZeros((int) objectGUID[5] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[4] & 0xFF)); + displayStr.append("-"); + displayStr.append(prefixZeros((int) objectGUID[7] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[6] & 0xFF)); + displayStr.append("-"); + displayStr.append(prefixZeros((int) objectGUID[8] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[9] & 0xFF)); + displayStr.append("-"); + displayStr.append(prefixZeros((int) objectGUID[10] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[11] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[12] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[13] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[14] & 0xFF)); + displayStr.append(prefixZeros((int) objectGUID[15] & 0xFF)); + + return displayStr.toString(); + } + + private static String prefixZeros(int value) { + if (value <= 0xF) { + StringBuilder sb = new StringBuilder("0"); + sb.append(Integer.toHexString(value)); + return sb.toString(); + } else { + return Integer.toHexString(value); + } + } + + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/kerberos/LDAPProviderKerberosConfig.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/kerberos/LDAPProviderKerberosConfig.java new file mode 100644 index 0000000000..87bfcaf137 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/kerberos/LDAPProviderKerberosConfig.java @@ -0,0 +1,39 @@ +/* + * 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.storage.ldap.kerberos; + +import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.component.ComponentModel; +import org.keycloak.federation.kerberos.CommonKerberosConfig; +import org.keycloak.storage.ldap.LDAPStorageProvider; + +/** + * Configuration specific to {@link LDAPStorageProvider} + * + * @author Marek Posolda + */ +public class LDAPProviderKerberosConfig extends CommonKerberosConfig { + + public LDAPProviderKerberosConfig(ComponentModel componentModel) { + super(componentModel); + } + + public boolean isUseKerberosForPasswordAuthentication() { + return Boolean.valueOf(componentModel.getConfig().getFirst(KerberosConstants.USE_KERBEROS_FOR_PASSWORD_AUTHENTICATION)); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapper.java new file mode 100644 index 0000000000..c6edd96ef6 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapper.java @@ -0,0 +1,106 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; +import org.keycloak.storage.user.SynchronizationResult; + +import javax.naming.AuthenticationException; +import java.util.Collections; +import java.util.List; + +/** + * Stateful per-request object + * + * @author Marek Posolda + */ +public abstract class AbstractLDAPStorageMapper { + + protected final ComponentModel mapperModel; + protected final LDAPStorageProvider ldapProvider; + protected final RealmModel realm; + + public AbstractLDAPStorageMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, RealmModel realm) { + this.mapperModel = mapperModel; + this.ldapProvider = ldapProvider; + this.realm = realm; + } + + /** + * @see LDAPStorageMapper#syncDataFromFederationProviderToKeycloak(ComponentModel, LDAPStorageProvider, KeycloakSession, RealmModel) + */ + public SynchronizationResult syncDataFromFederationProviderToKeycloak() { + return new SynchronizationResult(); + } + + /** + * @see LDAPStorageMapper#syncDataFromKeycloakToFederationProvider(ComponentModel, LDAPStorageProvider, KeycloakSession, RealmModel) + */ + public SynchronizationResult syncDataFromKeycloakToFederationProvider() { + return new SynchronizationResult(); + } + + /** + * @see LDAPStorageMapper#beforeLDAPQuery(ComponentModel, LDAPQuery) + */ + public abstract void beforeLDAPQuery(LDAPQuery query); + + /** + * @see LDAPStorageMapper#proxy(ComponentModel, LDAPStorageProvider, LDAPObject, UserModel, RealmModel) + */ + public abstract UserModel proxy(LDAPObject ldapUser, UserModel delegate); + + /** + * @see LDAPStorageMapper#onRegisterUserToLDAP(ComponentModel, LDAPStorageProvider, LDAPObject, UserModel, RealmModel) + */ + public abstract void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser); + + /** + * @see LDAPStorageMapper#onImportUserFromLDAP(ComponentModel, LDAPStorageProvider, LDAPObject, UserModel, RealmModel, boolean) + */ + public abstract void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, boolean isCreate); + + public List getGroupMembers(GroupModel group, int firstResult, int maxResults) { + return Collections.emptyList(); + } + + public boolean onAuthenticationFailure(LDAPObject ldapUser, UserModel user, AuthenticationException ldapException) { + return false; + } + + + public static boolean parseBooleanParameter(ComponentModel mapperModel, String paramName) { + String paramm = mapperModel.getConfig().getFirst(paramName); + return Boolean.parseBoolean(paramm); + } + + public LDAPStorageProvider getLdapProvider() { + return ldapProvider; + } + + public RealmModel getRealm() { + return realm; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapperFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapperFactory.java new file mode 100755 index 0000000000..bacac30a1e --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapperFactory.java @@ -0,0 +1,89 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.Config; +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.idm.UserFederationMapperSyncConfigRepresentation; +import org.keycloak.storage.ldap.LDAPStorageProvider; + +import java.util.List; + +/** + * @author Marek Posolda + */ +public abstract class AbstractLDAPStorageMapperFactory implements LDAPStorageMapperFactory { + + // Used to map attributes from LDAP to UserModel attributes + public static final String ATTRIBUTE_MAPPER_CATEGORY = "Attribute Mapper"; + + // Used to map roles from LDAP to UserModel users + public static final String ROLE_MAPPER_CATEGORY = "Role Mapper"; + + + // Used to map group from LDAP to UserModel users + public static final String GROUP_MAPPER_CATEGORY = "Group Mapper"; + + @Override + public void init(Config.Scope config) { + } + + @Override + public LDAPStorageMapper create(KeycloakSession session, ComponentModel model) { + return new LDAPStorageMapperBridge(this); } + + // Used just by LDAPFederationMapperBridge. + protected abstract AbstractLDAPStorageMapper createMapper(ComponentModel mapperModel, LDAPStorageProvider federationProvider, RealmModel realm); + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public UserFederationMapperSyncConfigRepresentation getSyncConfig() { + return new UserFederationMapperSyncConfigRepresentation(false, null, false, null); + } + + @Override + public void close() { + } + + public static ProviderConfigProperty createConfigProperty(String name, String label, String helpText, String type, List options) { + ProviderConfigProperty configProperty = new ProviderConfigProperty(); + configProperty.setName(name); + configProperty.setLabel(label); + configProperty.setHelpText(helpText); + configProperty.setType(type); + configProperty.setOptions(options); + return configProperty; + } + + protected void checkMandatoryConfigAttribute(String name, String displayName, ComponentModel mapperModel) throws ComponentValidationException { + String attrConfigValue = mapperModel.getConfig().getFirst(name); + if (attrConfigValue == null || attrConfigValue.trim().isEmpty()) { + throw new ComponentValidationException("Missing configuration for '" + displayName + "'"); + } + } + + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapper.java new file mode 100644 index 0000000000..1806ecdf89 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapper.java @@ -0,0 +1,195 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.Condition; +import org.keycloak.storage.ldap.idm.query.internal.EqualCondition; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; + +import java.util.HashSet; +import java.util.Set; + +/** + * Mapper useful for the LDAP deployments when some attribute (usually CN) is mapped to full name of user + * + * @author Marek Posolda + */ +public class FullNameLDAPStorageMapper extends AbstractLDAPStorageMapper { + + public static final String LDAP_FULL_NAME_ATTRIBUTE = "ldap.full.name.attribute"; + public static final String READ_ONLY = "read.only"; + public static final String WRITE_ONLY = "write.only"; + + + public FullNameLDAPStorageMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, RealmModel realm) { + super(mapperModel, ldapProvider, realm); + } + + @Override + public void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, boolean isCreate) { + if (isWriteOnly()) { + return; + } + + String ldapFullNameAttrName = getLdapFullNameAttrName(); + String fullName = ldapUser.getAttributeAsString(ldapFullNameAttrName); + if (fullName == null) { + return; + } + + fullName = fullName.trim(); + if (!fullName.isEmpty()) { + int lastSpaceIndex = fullName.lastIndexOf(" "); + if (lastSpaceIndex == -1) { + user.setLastName(fullName); + } else { + user.setFirstName(fullName.substring(0, lastSpaceIndex)); + user.setLastName(fullName.substring(lastSpaceIndex + 1)); + } + } + } + + @Override + public void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser) { + String ldapFullNameAttrName = getLdapFullNameAttrName(); + String fullName = getFullName(localUser.getFirstName(), localUser.getLastName()); + ldapUser.setSingleAttribute(ldapFullNameAttrName, fullName); + + if (isReadOnly()) { + ldapUser.addReadOnlyAttributeName(ldapFullNameAttrName); + } + } + + @Override + public UserModel proxy(LDAPObject ldapUser, UserModel delegate) { + if (ldapProvider.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE && !isReadOnly()) { + + + TxAwareLDAPUserModelDelegate txDelegate = new TxAwareLDAPUserModelDelegate(delegate, ldapProvider, ldapUser) { + + @Override + public void setFirstName(String firstName) { + super.setFirstName(firstName); + setFullNameToLDAPObject(); + } + + @Override + public void setLastName(String lastName) { + super.setLastName(lastName); + setFullNameToLDAPObject(); + } + + private void setFullNameToLDAPObject() { + String fullName = getFullName(getFirstName(), getLastName()); + if (logger.isTraceEnabled()) { + logger.tracef("Pushing full name attribute to LDAP. Full name: %s", fullName); + } + + ensureTransactionStarted(); + + String ldapFullNameAttrName = getLdapFullNameAttrName(); + ldapUser.setSingleAttribute(ldapFullNameAttrName, fullName); + } + + }; + + return txDelegate; + } else { + return delegate; + } + } + + @Override + public void beforeLDAPQuery(LDAPQuery query) { + if (isWriteOnly()) { + return; + } + + String ldapFullNameAttrName = getLdapFullNameAttrName(); + query.addReturningLdapAttribute(ldapFullNameAttrName); + + // Change conditions and compute condition for fullName from the conditions for firstName and lastName. Right now just "equal" condition is supported + EqualCondition firstNameCondition = null; + EqualCondition lastNameCondition = null; + Set conditionsCopy = new HashSet(query.getConditions()); + for (Condition condition : conditionsCopy) { + String paramName = condition.getParameterName(); + if (paramName != null) { + if (paramName.equals(UserModel.FIRST_NAME)) { + firstNameCondition = (EqualCondition) condition; + query.getConditions().remove(condition); + } else if (paramName.equals(UserModel.LAST_NAME)) { + lastNameCondition = (EqualCondition) condition; + query.getConditions().remove(condition); + } else if (paramName.equals(LDAPConstants.GIVENNAME)) { + // Some previous mapper already converted it to LDAP name + firstNameCondition = (EqualCondition) condition; + } else if (paramName.equals(LDAPConstants.SN)) { + // Some previous mapper already converted it to LDAP name + lastNameCondition = (EqualCondition) condition; + } + } + } + + + String fullName = null; + if (firstNameCondition != null && lastNameCondition != null) { + fullName = firstNameCondition.getValue() + " " + lastNameCondition.getValue(); + } else if (firstNameCondition != null) { + fullName = (String) firstNameCondition.getValue(); + } else if (lastNameCondition != null) { + fullName = (String) lastNameCondition.getValue(); + } else { + return; + } + EqualCondition fullNameCondition = new EqualCondition(ldapFullNameAttrName, fullName); + query.addWhereCondition(fullNameCondition); + } + + protected String getLdapFullNameAttrName() { + String ldapFullNameAttrName = mapperModel.getConfig().getFirst(LDAP_FULL_NAME_ATTRIBUTE); + return ldapFullNameAttrName == null ? LDAPConstants.CN : ldapFullNameAttrName; + } + + protected String getFullName(String firstName, String lastName) { + if (firstName != null && lastName != null) { + return firstName + " " + lastName; + } else if (firstName != null) { + return firstName; + } else if (lastName != null) { + return lastName; + } else { + return LDAPConstants.EMPTY_ATTRIBUTE_VALUE; + } + } + + private boolean isReadOnly() { + return parseBooleanParameter(mapperModel, READ_ONLY); + } + + private boolean isWriteOnly() { + return parseBooleanParameter(mapperModel, WRITE_ONLY); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapperFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapperFactory.java new file mode 100755 index 0000000000..3cfa6c5204 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapperFactory.java @@ -0,0 +1,124 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserFederationProvider; +import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Marek Posolda + */ +public class FullNameLDAPStorageMapperFactory extends AbstractLDAPStorageMapperFactory { + + public static final String PROVIDER_ID = "full-name-ldap-mapper"; + + protected static final List configProperties; + + static { + configProperties = getConfigProps(null); + } + + private static List getConfigProps(ComponentModel parent) { + boolean readOnly = false; + if (parent != null) { + LDAPConfig config = new LDAPConfig(parent.getConfig()); + readOnly = config.getEditMode() != LDAPStorageProviderFactory.EditMode.WRITABLE; + } + + + return ProviderConfigurationBuilder.create() + .property().name(FullNameLDAPStorageMapper.LDAP_FULL_NAME_ATTRIBUTE) + .label("LDAP Full Name Attribute") + .helpText("Name of LDAP attribute, which contains fullName of user. Usually it will be 'cn' ") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(LDAPConstants.CN) + .add() + .property().name(FullNameLDAPStorageMapper.READ_ONLY) + .label("Read Only") + .helpText("For Read-only is data imported from LDAP to Keycloak DB, but it's not saved back to LDAP when user is updated in Keycloak.") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(String.valueOf(readOnly)) + .add() + .property().name(FullNameLDAPStorageMapper.WRITE_ONLY) + .label("Write Only") + .helpText("For Write-only is data propagated to LDAP when user is created or updated in Keycloak. But this mapper is not used to propagate data from LDAP back into Keycloak. " + + "This setting is useful if you configured separate firstName and lastName attribute mappers and you want to use those to read attribute from LDAP into Keycloak") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(String.valueOf(!readOnly)) + .add() + .build(); + } + + @Override + public String getHelpText() { + return "Used to map full-name of user from single attribute in LDAP (usually 'cn' attribute) to firstName and lastName attributes of UserModel in Keycloak DB"; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { + checkMandatoryConfigAttribute(FullNameLDAPStorageMapper.LDAP_FULL_NAME_ATTRIBUTE, "LDAP Full Name Attribute", config); + + + boolean readOnly = AbstractLDAPStorageMapper.parseBooleanParameter(config, FullNameLDAPStorageMapper.READ_ONLY); + boolean writeOnly = AbstractLDAPStorageMapper.parseBooleanParameter(config, FullNameLDAPStorageMapper.WRITE_ONLY); + + ComponentModel parent = realm.getComponent(config.getParentId()); + if (parent == null) { + throw new ComponentValidationException("can't find parent component model"); + + } + LDAPConfig cfg = new LDAPConfig(parent.getConfig()); + LDAPStorageProviderFactory.EditMode editMode = cfg.getEditMode(); + + if (writeOnly && cfg.getEditMode() != LDAPStorageProviderFactory.EditMode.WRITABLE) { + throw new ComponentValidationException("ldapErrorCantWriteOnlyForReadOnlyLdap"); + } + if (writeOnly && readOnly) { + throw new ComponentValidationException("ldapErrorCantWriteOnlyAndReadOnly"); + } + } + + @Override + protected AbstractLDAPStorageMapper createMapper(ComponentModel mapperModel, LDAPStorageProvider federationProvider, RealmModel realm) { + return new FullNameLDAPStorageMapper(mapperModel, federationProvider, realm); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPRoleStorageMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPRoleStorageMapper.java new file mode 100644 index 0000000000..ca79a1905e --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPRoleStorageMapper.java @@ -0,0 +1,126 @@ +/* + * 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.storage.ldap.mappers; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; + +import java.util.Set; + +/** + * @author Marek Posolda + */ +public class HardcodedLDAPRoleStorageMapper extends AbstractLDAPStorageMapper { + + private static final Logger logger = Logger.getLogger(HardcodedLDAPRoleStorageMapper.class); + + public static final String ROLE = "role"; + + public HardcodedLDAPRoleStorageMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, RealmModel realm) { + super(mapperModel, ldapProvider, realm); + } + + @Override + public void beforeLDAPQuery(LDAPQuery query) { + } + + @Override + public UserModel proxy(LDAPObject ldapUser, UserModel delegate) { + return new UserModelDelegate(delegate) { + + @Override + public Set getRealmRoleMappings() { + Set roles = super.getRealmRoleMappings(); + + RoleModel role = getRole(); + if (role != null && role.getContainer().equals(realm)) { + roles.add(role); + } + + return roles; + } + + @Override + public Set getClientRoleMappings(ClientModel app) { + Set roles = super.getClientRoleMappings(app); + + RoleModel role = getRole(); + if (role != null && role.getContainer().equals(app)) { + roles.add(role); + } + + return roles; + } + + @Override + public boolean hasRole(RoleModel role) { + return super.hasRole(role) || role.equals(getRole()); + } + + @Override + public Set getRoleMappings() { + Set roles = super.getRoleMappings(); + + RoleModel role = getRole(); + if (role != null) { + roles.add(role); + } + + return roles; + } + + @Override + public void deleteRoleMapping(RoleModel role) { + if (role.equals(getRole())) { + throw new ModelException("Not possible to delete role. It's hardcoded by LDAP mapper"); + } else { + super.deleteRoleMapping(role); + } + } + }; + } + + @Override + public void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser) { + + } + + @Override + public void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, boolean isCreate) { + + } + + private RoleModel getRole() { + String roleName = mapperModel.getConfig().getFirst(HardcodedLDAPRoleStorageMapper.ROLE); + RoleModel role = KeycloakModelUtils.getRoleFromString(realm, roleName); + if (role == null) { + logger.warnf("Hardcoded role '%s' configured in mapper '%s' is not available anymore"); + } + return role; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPRoleStorageMapperFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPRoleStorageMapperFactory.java new file mode 100644 index 0000000000..c833504f20 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/HardcodedLDAPRoleStorageMapperFactory.java @@ -0,0 +1,78 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.storage.ldap.LDAPStorageProvider; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Marek Posolda + */ +public class HardcodedLDAPRoleStorageMapperFactory extends AbstractLDAPStorageMapperFactory { + + public static final String PROVIDER_ID = "hardcoded-ldap-role-mapper"; + protected static final List configProperties = new ArrayList(); + + static { + ProviderConfigProperty roleAttr = createConfigProperty(HardcodedLDAPRoleStorageMapper.ROLE, "Role", + "Role to grant to user. Click 'Select Role' button to browse roles, or just type it in the textbox. To reference an application role the syntax is appname.approle, i.e. myapp.myrole", + ProviderConfigProperty.ROLE_TYPE, null); + configProperties.add(roleAttr); + } + + @Override + public String getHelpText() { + return "When user is imported from LDAP, he will be automatically added into this configured role."; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { + String roleName = config.getConfig().getFirst(HardcodedLDAPRoleStorageMapper.ROLE); + if (roleName == null) { + throw new ComponentValidationException("Role can't be null"); + } + RoleModel role = KeycloakModelUtils.getRoleFromString(realm, roleName); + if (role == null) { + throw new ComponentValidationException("There is no role corresponding to configured value"); + } + } + + @Override + protected AbstractLDAPStorageMapper createMapper(ComponentModel mapperModel, LDAPStorageProvider federationProvider, RealmModel realm) { + return new HardcodedLDAPRoleStorageMapper(mapperModel, federationProvider, realm); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPMappersComparator.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPMappersComparator.java new file mode 100644 index 0000000000..fda9b8d502 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPMappersComparator.java @@ -0,0 +1,114 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.UserModel; +import org.keycloak.storage.ldap.LDAPConfig; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * TODO: Possibly add "priority" instead of hardcoding behaviour + * + * @author Marek Posolda + */ +public class LDAPMappersComparator { + + public static List sortAsc(LDAPConfig ldapConfig, Collection mappers) { + Comparator comparator = new ImportantFirstComparator(ldapConfig); + + List result = new ArrayList<>(mappers); + Collections.sort(result, comparator); + return result; + } + + public static List sortDesc(LDAPConfig ldapConfig, Collection mappers) { + Comparator comparator = new ImportantFirstComparator(ldapConfig).reversed(); + + List result = new ArrayList<>(mappers); + Collections.sort(result, comparator); + return result; + } + + + private static class ImportantFirstComparator implements Comparator { + + private final LDAPConfig ldapConfig; + + public ImportantFirstComparator(LDAPConfig ldapConfig) { + this.ldapConfig = ldapConfig; + } + + @Override + public int compare(ComponentModel o1, ComponentModel o2) { + // UserAttributeLDAPFederationMapper first + boolean isO1AttrMapper = o1.getProviderType().equals(UserAttributeLDAPStorageMapperFactory.PROVIDER_ID); + boolean isO2AttrMapper = o2.getProviderType().equals(UserAttributeLDAPStorageMapperFactory.PROVIDER_ID); + if (!isO1AttrMapper) { + if (isO2AttrMapper) { + return 1; + } else { + return 0; + } + } else if (!isO2AttrMapper) { + return -1; + } + + // Mapper for "username" attribute first + String model1 = o1.getConfig().getFirst(UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE); + String model2 = o2.getConfig().getFirst(UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE); + boolean isO1UsernameMapper = model1 != null && model1.equalsIgnoreCase(UserModel.USERNAME); + boolean isO2UsernameMapper = model2 != null && model2.equalsIgnoreCase(UserModel.USERNAME); + if (!isO1UsernameMapper) { + if (isO2UsernameMapper) { + return 1; + } else { + return 0; + } + } else if (!isO2UsernameMapper) { + return -1; + } + + // The username mapper corresponding to the same like configured username for federationProvider is first + String o1LdapAttr = o1.getConfig().getFirst(UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE); + String o2LdapAttr = o2.getConfig().getFirst(UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE); + boolean isO1LdapAttr = o1LdapAttr != null && ldapConfig.getUsernameLdapAttribute().equalsIgnoreCase(o1LdapAttr); + boolean isO2LdapAttr = o2LdapAttr != null && ldapConfig.getUsernameLdapAttribute().equalsIgnoreCase(o2LdapAttr); + + if (!isO1LdapAttr) { + if (isO2LdapAttr) { + return 1; + } else { + return 0; + } + } else if (!isO2LdapAttr) { + return -1; + } + + return 0; + } + + } + + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapper.java new file mode 100644 index 0000000000..39831e1749 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapper.java @@ -0,0 +1,125 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.provider.Provider; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; +import org.keycloak.storage.user.SynchronizationResult; + +import javax.naming.AuthenticationException; +import java.util.List; + +/** + * @author Marek Posolda + */ +public interface LDAPStorageMapper extends Provider { + + /** + * Sync data from federated storage to Keycloak. It's useful just if mapper needs some data preloaded from federated storage (For example + * load roles from federated provider and sync them to Keycloak database) + * + * Applicable just if sync is supported + * + * @param mapperModel + * @param provider + * @param session + * @param realm + */ + SynchronizationResult syncDataFromFederationProviderToKeycloak(ComponentModel mapperModel, LDAPStorageProvider provider, KeycloakSession session, RealmModel realm); + + /** + * Sync data from Keycloak back to federated storage + * + * @param mapperModel + * @param provider + * @param session + * @param realm + */ + SynchronizationResult syncDataFromKeycloakToFederationProvider(ComponentModel mapperModel, LDAPStorageProvider provider, KeycloakSession session, RealmModel realm); + + /** + * Return empty list if doesn't support storing of groups + */ + List getGroupMembers(ComponentModel mapperModel, LDAPStorageProvider provider, RealmModel realm, GroupModel group, int firstResult, int maxResults); + + /** + * Called when importing user from LDAP to local keycloak DB. + * + * @param mapperModel + * @param ldapProvider + * @param ldapUser + * @param user + * @param realm + * @param isCreate true if we importing new user from LDAP. False if user already exists in Keycloak, but we are upgrading (syncing) it from LDAP + */ + void onImportUserFromLDAP(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, LDAPObject ldapUser, UserModel user, RealmModel realm, boolean isCreate); + + + /** + * Called when register new user to LDAP - just after user was created in Keycloak DB + * + * @param mapperModel + * @param ldapProvider + * @param ldapUser + * @param localUser + * @param realm + */ + void onRegisterUserToLDAP(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, LDAPObject ldapUser, UserModel localUser, RealmModel realm); + + + /** + * Called when invoke proxy on LDAP federation provider + * + * @param mapperModel + * @param ldapProvider + * @param ldapUser + * @param delegate + * @param realm + * @return + */ + UserModel proxy(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, LDAPObject ldapUser, UserModel delegate, RealmModel realm); + + + /** + * Called before LDAP Identity query for retrieve LDAP users was executed. It allows to change query somehow (add returning attributes from LDAP, change conditions etc) + * + * @param mapperModel + * @param query + */ + void beforeLDAPQuery(ComponentModel mapperModel, LDAPQuery query); + + /** + * Called when LDAP authentication of specified user fails. If any mapper returns true from this method, AuthenticationException won't be rethrown! + * + * @param mapperModel + * @param ldapProvider + * @param realm + * @param user + * @param ldapUser + * @param ldapException + * @return true if mapper processed the AuthenticationException and did some actions based on that. In that case, AuthenticationException won't be rethrown! + */ + boolean onAuthenticationFailure(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, LDAPObject ldapUser, UserModel user, AuthenticationException ldapException, RealmModel realm); +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperBridge.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperBridge.java new file mode 100644 index 0000000000..f3919cc76c --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperBridge.java @@ -0,0 +1,98 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; +import org.keycloak.storage.user.SynchronizationResult; + +import javax.naming.AuthenticationException; +import java.util.List; + +/** + * Sufficient if mapper implementation is stateless and doesn't need to "close" any state + * + * @author Marek Posolda + */ +public class LDAPStorageMapperBridge implements LDAPStorageMapper { + + private final AbstractLDAPStorageMapperFactory factory; + + public LDAPStorageMapperBridge(AbstractLDAPStorageMapperFactory factory) { + this.factory = factory; + } + + // Sync groups from LDAP to Keycloak DB + @Override + public SynchronizationResult syncDataFromFederationProviderToKeycloak(ComponentModel mapperModel, LDAPStorageProvider federationProvider, KeycloakSession session, RealmModel realm) { + return getDelegate(mapperModel, federationProvider, realm).syncDataFromFederationProviderToKeycloak(); + } + + @Override + public SynchronizationResult syncDataFromKeycloakToFederationProvider(ComponentModel mapperModel, LDAPStorageProvider federationProvider, KeycloakSession session, RealmModel realm) { + return getDelegate(mapperModel, federationProvider, realm).syncDataFromKeycloakToFederationProvider(); + } + + @Override + public void onImportUserFromLDAP(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, LDAPObject ldapUser, UserModel user, RealmModel realm, boolean isCreate) { + getDelegate(mapperModel, ldapProvider, realm).onImportUserFromLDAP(ldapUser, user, isCreate); + } + + @Override + public void onRegisterUserToLDAP(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, LDAPObject ldapUser, UserModel localUser, RealmModel realm) { + getDelegate(mapperModel, ldapProvider, realm).onRegisterUserToLDAP(ldapUser, localUser); + } + + @Override + public UserModel proxy(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, LDAPObject ldapUser, UserModel delegate, RealmModel realm) { + return getDelegate(mapperModel, ldapProvider, realm).proxy(ldapUser, delegate); + } + + @Override + public void beforeLDAPQuery(ComponentModel mapperModel, LDAPQuery query) { + // Improve if needed + getDelegate(mapperModel, query.getLdapProvider(), null).beforeLDAPQuery(query); + } + + + @Override + public List getGroupMembers(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, RealmModel realm, GroupModel group, int firstResult, int maxResults) { + return getDelegate(mapperModel, ldapProvider, realm).getGroupMembers(group, firstResult, maxResults); + } + + @Override + public boolean onAuthenticationFailure(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, LDAPObject ldapUser, UserModel user, AuthenticationException ldapException, RealmModel realm) { + return getDelegate(mapperModel, ldapProvider, realm).onAuthenticationFailure(ldapUser, user, ldapException); + } + + private AbstractLDAPStorageMapper getDelegate(ComponentModel mapperModel, LDAPStorageProvider federationProvider, RealmModel realm) { + LDAPStorageProvider ldapProvider = (LDAPStorageProvider) federationProvider; + return factory.createMapper(mapperModel, ldapProvider, realm); + } + + @Override + public void close() { + + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperFactory.java new file mode 100644 index 0000000000..3b3f58cd0e --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperFactory.java @@ -0,0 +1,99 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.Config; +import org.keycloak.component.ComponentFactory; +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.component.SubComponentFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.idm.UserFederationMapperSyncConfigRepresentation; + +import java.util.Collections; +import java.util.List; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public interface LDAPStorageMapperFactory extends SubComponentFactory { + /** + * called per Keycloak transaction. + * + * @param session + * @param model + * @return + */ + T create(KeycloakSession session, ComponentModel model); + + UserFederationMapperSyncConfigRepresentation getSyncConfig(); + + /** + * This is the name of the provider and will be showed in the admin console as an option. + * + * @return + */ + @Override + String getId(); + + @Override + default void init(Config.Scope config) { + + } + + @Override + default void postInit(KeycloakSessionFactory factory) { + + } + + @Override + default void close() { + + } + + @Override + default String getHelpText() { + return ""; + } + + @Override + default List getConfigProperties() { + return Collections.EMPTY_LIST; + } + + @Override + default void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { + + } + + /** + * Called when UserStorageProviderModel is created. This allows you to do initialization of any additional configuration + * you need to add. For example, you may be introspecting a database or ldap schema to automatically create mappings. + * + * @param session + * @param realm + * @param model + */ + @Override + default void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) { + + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperSpi.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperSpi.java new file mode 100644 index 0000000000..c7b8185dbf --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperSpi.java @@ -0,0 +1,50 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.credential.CredentialProvider; +import org.keycloak.credential.CredentialProviderFactory; +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * @author Kunal Kerkar + */ +public class LDAPStorageMapperSpi implements Spi { + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return "ldap-mapper"; + } + + @Override + public Class getProviderClass() { + return LDAPStorageMapper.class; + } + + @Override + public Class getProviderFactoryClass() { + return LDAPStorageMapperFactory.class; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/PasswordUpdated.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/PasswordUpdated.java new file mode 100644 index 0000000000..c4d7b5eef8 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/PasswordUpdated.java @@ -0,0 +1,29 @@ +/* + * 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.storage.ldap.mappers; + +import org.keycloak.credential.CredentialInput; +import org.keycloak.models.UserModel; +import org.keycloak.storage.ldap.idm.model.LDAPObject; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public interface PasswordUpdated { + void passwordUpdated(UserModel user, LDAPObject ldapUser, CredentialInput input); +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/TxAwareLDAPUserModelDelegate.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/TxAwareLDAPUserModelDelegate.java new file mode 100644 index 0000000000..4fc5cafb25 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/TxAwareLDAPUserModelDelegate.java @@ -0,0 +1,136 @@ +/* + * 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.storage.ldap.mappers; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakTransaction; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; + +/** + * @author Marek Posolda + */ +public abstract class TxAwareLDAPUserModelDelegate extends UserModelDelegate { + + public static final Logger logger = Logger.getLogger(TxAwareLDAPUserModelDelegate.class); + + protected LDAPStorageProvider provider; + protected LDAPObject ldapUser; + private final LDAPTransaction transaction; + + public TxAwareLDAPUserModelDelegate(UserModel delegate, LDAPStorageProvider provider, LDAPObject ldapUser) { + super(delegate); + this.provider = provider; + this.ldapUser = ldapUser; + this.transaction = findOrCreateTransaction(); + } + + public LDAPTransaction getTransaction() { + return transaction; + } + + // Try to find transaction in any delegate. We want to enlist just single transaction per all delegates + private LDAPTransaction findOrCreateTransaction() { + UserModelDelegate delegate = this; + while (true) { + UserModel deleg = delegate.getDelegate(); + if (!(deleg instanceof UserModelDelegate)) { + return new LDAPTransaction(); + } else { + delegate = (UserModelDelegate) deleg; + } + + if (delegate instanceof TxAwareLDAPUserModelDelegate) { + TxAwareLDAPUserModelDelegate txDelegate = (TxAwareLDAPUserModelDelegate) delegate; + return txDelegate.getTransaction(); + } + } + } + + protected void ensureTransactionStarted() { + if (transaction.state == TransactionState.NOT_STARTED) { + if (logger.isTraceEnabled()) { + logger.trace("Starting and enlisting transaction for object " + ldapUser.getDn().toString()); + } + + this.provider.getSession().getTransactionManager().enlistAfterCompletion(transaction); + } + } + + + + protected class LDAPTransaction implements KeycloakTransaction { + + protected TransactionState state = TransactionState.NOT_STARTED; + + @Override + public void begin() { + if (state != TransactionState.NOT_STARTED) { + throw new IllegalStateException("Transaction already started"); + } + + state = TransactionState.STARTED; + } + + @Override + public void commit() { + if (state != TransactionState.STARTED) { + throw new IllegalStateException("Transaction in illegal state for commit: " + state); + } + + if (logger.isTraceEnabled()) { + logger.trace("Transaction commit! Updating LDAP attributes for object " + ldapUser.getDn().toString() + ", attributes: " + ldapUser.getAttributes()); + } + + provider.getLdapIdentityStore().update(ldapUser); + state = TransactionState.FINISHED; + } + + @Override + public void rollback() { + if (state != TransactionState.STARTED && state != TransactionState.ROLLBACK_ONLY) { + throw new IllegalStateException("Transaction in illegal state for rollback: " + state); + } + + logger.warn("Transaction rollback! Ignoring LDAP updates for object " + ldapUser.getDn().toString()); + state = TransactionState.FINISHED; + } + + @Override + public void setRollbackOnly() { + state = TransactionState.ROLLBACK_ONLY; + } + + @Override + public boolean getRollbackOnly() { + return state == TransactionState.ROLLBACK_ONLY; + } + + @Override + public boolean isActive() { + return state == TransactionState.STARTED || state == TransactionState.ROLLBACK_ONLY; + } + } + + protected enum TransactionState { + NOT_STARTED, STARTED, ROLLBACK_ONLY, FINISHED + } + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java new file mode 100644 index 0000000000..6c5fbb2f73 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java @@ -0,0 +1,369 @@ +/* + * 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.storage.ldap.mappers; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.models.utils.reflection.Property; +import org.keycloak.models.utils.reflection.PropertyCriteria; +import org.keycloak.models.utils.reflection.PropertyQueries; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.Condition; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper { + + private static final Logger logger = Logger.getLogger(UserAttributeLDAPStorageMapper.class); + + private static final Map> userModelProperties; + + static { + Map> userModelProps = PropertyQueries.createQuery(UserModel.class).addCriteria(new PropertyCriteria() { + + @Override + public boolean methodMatches(Method m) { + if ((m.getName().startsWith("get") || m.getName().startsWith("is")) && m.getParameterTypes().length > 0) { + return false; + } + + return true; + } + + }).getResultList(); + + // Convert to be keyed by lower-cased attribute names + userModelProperties = new HashMap<>(); + for (Map.Entry> entry : userModelProps.entrySet()) { + userModelProperties.put(entry.getKey().toLowerCase(), entry.getValue()); + } + } + + public static final String USER_MODEL_ATTRIBUTE = "user.model.attribute"; + public static final String LDAP_ATTRIBUTE = "ldap.attribute"; + public static final String READ_ONLY = "read.only"; + public static final String ALWAYS_READ_VALUE_FROM_LDAP = "always.read.value.from.ldap"; + public static final String IS_MANDATORY_IN_LDAP = "is.mandatory.in.ldap"; + + public UserAttributeLDAPStorageMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, RealmModel realm) { + super(mapperModel, ldapProvider, realm); + } + + @Override + public void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, boolean isCreate) { + String userModelAttrName = mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE); + String ldapAttrName = mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE); + + Property userModelProperty = userModelProperties.get(userModelAttrName.toLowerCase()); + + if (userModelProperty != null) { + + // we have java property on UserModel + String ldapAttrValue = ldapUser.getAttributeAsString(ldapAttrName); + + checkDuplicateEmail(userModelAttrName, ldapAttrValue, realm, ldapProvider.getSession(), user); + + setPropertyOnUserModel(userModelProperty, user, ldapAttrValue); + } else { + + // we don't have java property. Let's set attribute + Set ldapAttrValue = ldapUser.getAttributeAsSet(ldapAttrName); + if (ldapAttrValue != null) { + user.setAttribute(userModelAttrName, new ArrayList<>(ldapAttrValue)); + } else { + user.removeAttribute(userModelAttrName); + } + } + } + + @Override + public void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser) { + String userModelAttrName = mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE); + String ldapAttrName = mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE); + boolean isMandatoryInLdap = parseBooleanParameter(mapperModel, IS_MANDATORY_IN_LDAP); + + Property userModelProperty = userModelProperties.get(userModelAttrName.toLowerCase()); + + if (userModelProperty != null) { + + // we have java property on UserModel. Assuming we support just properties of simple types + Object attrValue = userModelProperty.getValue(localUser); + + if (attrValue == null) { + if (isMandatoryInLdap) { + ldapUser.setSingleAttribute(ldapAttrName, LDAPConstants.EMPTY_ATTRIBUTE_VALUE); + } else { + ldapUser.setAttribute(ldapAttrName, new LinkedHashSet()); + } + } else { + ldapUser.setSingleAttribute(ldapAttrName, attrValue.toString()); + } + } else { + + // we don't have java property. Let's set attribute + List attrValues = localUser.getAttribute(userModelAttrName); + + if (attrValues.size() == 0) { + if (isMandatoryInLdap) { + ldapUser.setSingleAttribute(ldapAttrName, LDAPConstants.EMPTY_ATTRIBUTE_VALUE); + } else { + ldapUser.setAttribute(ldapAttrName, new LinkedHashSet()); + } + } else { + ldapUser.setAttribute(ldapAttrName, new LinkedHashSet<>(attrValues)); + } + } + + if (isReadOnly()) { + ldapUser.addReadOnlyAttributeName(ldapAttrName); + } + } + + // throw ModelDuplicateException if there is different user in model with same email + protected void checkDuplicateEmail(String userModelAttrName, String email, RealmModel realm, KeycloakSession session, UserModel user) { + if (UserModel.EMAIL.equalsIgnoreCase(userModelAttrName)) { + // lowercase before search + email = KeycloakModelUtils.toLowerCaseSafe(email); + + UserModel that = session.userLocalStorage().getUserByEmail(email, realm); + if (that != null && !that.getId().equals(user.getId())) { + session.getTransactionManager().setRollbackOnly(); + String exceptionMessage = String.format("Can't import user '%s' from LDAP because email '%s' already exists in Keycloak. Existing user with this email is '%s'", user.getUsername(), email, that.getUsername()); + throw new ModelDuplicateException(exceptionMessage, UserModel.EMAIL); + } + } + } + + @Override + public UserModel proxy(final LDAPObject ldapUser, UserModel delegate) { + final String userModelAttrName = mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE); + final String ldapAttrName = mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE); + boolean isAlwaysReadValueFromLDAP = parseBooleanParameter(mapperModel, ALWAYS_READ_VALUE_FROM_LDAP); + final boolean isMandatoryInLdap = parseBooleanParameter(mapperModel, IS_MANDATORY_IN_LDAP); + + // For writable mode, we want to propagate writing of attribute to LDAP as well + if (ldapProvider.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE && !isReadOnly()) { + + delegate = new TxAwareLDAPUserModelDelegate(delegate, ldapProvider, ldapUser) { + + @Override + public void setSingleAttribute(String name, String value) { + setLDAPAttribute(name, value); + super.setSingleAttribute(name, value); + } + + @Override + public void setAttribute(String name, List values) { + setLDAPAttribute(name, values); + super.setAttribute(name, values); + } + + @Override + public void removeAttribute(String name) { + setLDAPAttribute(name, null); + super.removeAttribute(name); + } + + @Override + public void setEmail(String email) { + checkDuplicateEmail(userModelAttrName, email, realm, ldapProvider.getSession(), this); + + setLDAPAttribute(UserModel.EMAIL, email); + super.setEmail(email); + } + + @Override + public void setLastName(String lastName) { + setLDAPAttribute(UserModel.LAST_NAME, lastName); + super.setLastName(lastName); + } + + @Override + public void setFirstName(String firstName) { + setLDAPAttribute(UserModel.FIRST_NAME, firstName); + super.setFirstName(firstName); + } + + protected void setLDAPAttribute(String modelAttrName, Object value) { + if (modelAttrName.equalsIgnoreCase(userModelAttrName)) { + if (logger.isTraceEnabled()) { + logger.tracef("Pushing user attribute to LDAP. username: %s, Model attribute name: %s, LDAP attribute name: %s, Attribute value: %s", getUsername(), modelAttrName, ldapAttrName, value); + } + + ensureTransactionStarted(); + + if (value == null) { + if (isMandatoryInLdap) { + ldapUser.setSingleAttribute(ldapAttrName, LDAPConstants.EMPTY_ATTRIBUTE_VALUE); + } else { + ldapUser.setAttribute(ldapAttrName, new LinkedHashSet()); + } + } else if (value instanceof String) { + ldapUser.setSingleAttribute(ldapAttrName, (String) value); + } else { + List asList = (List) value; + if (asList.isEmpty() && isMandatoryInLdap) { + ldapUser.setSingleAttribute(ldapAttrName, LDAPConstants.EMPTY_ATTRIBUTE_VALUE); + } else { + ldapUser.setAttribute(ldapAttrName, new LinkedHashSet<>(asList)); + } + } + } + } + + }; + + } + + // We prefer to read attribute value from LDAP instead of from local Keycloak DB + if (isAlwaysReadValueFromLDAP) { + + delegate = new UserModelDelegate(delegate) { + + @Override + public String getFirstAttribute(String name) { + if (name.equalsIgnoreCase(userModelAttrName)) { + return ldapUser.getAttributeAsString(ldapAttrName); + } else { + return super.getFirstAttribute(name); + } + } + + @Override + public List getAttribute(String name) { + if (name.equalsIgnoreCase(userModelAttrName)) { + Collection ldapAttrValue = ldapUser.getAttributeAsSet(ldapAttrName); + if (ldapAttrValue == null) { + return Collections.emptyList(); + } else { + return new ArrayList<>(ldapAttrValue); + } + } else { + return super.getAttribute(name); + } + } + + @Override + public Map> getAttributes() { + Map> attrs = new HashMap<>(super.getAttributes()); + + // Ignore UserModel properties + if (userModelProperties.get(userModelAttrName.toLowerCase()) != null) { + return attrs; + } + + Set allLdapAttrValues = ldapUser.getAttributeAsSet(ldapAttrName); + if (allLdapAttrValues != null) { + attrs.put(userModelAttrName, new ArrayList<>(allLdapAttrValues)); + } + return attrs; + } + + @Override + public String getEmail() { + if (UserModel.EMAIL.equalsIgnoreCase(userModelAttrName)) { + return ldapUser.getAttributeAsString(ldapAttrName); + } else { + return super.getEmail(); + } + } + + @Override + public String getLastName() { + if (UserModel.LAST_NAME.equalsIgnoreCase(userModelAttrName)) { + return ldapUser.getAttributeAsString(ldapAttrName); + } else { + return super.getLastName(); + } + } + + @Override + public String getFirstName() { + if (UserModel.FIRST_NAME.equalsIgnoreCase(userModelAttrName)) { + return ldapUser.getAttributeAsString(ldapAttrName); + } else { + return super.getFirstName(); + } + } + + }; + } + + return delegate; + } + + @Override + public void beforeLDAPQuery(LDAPQuery query) { + String userModelAttrName = mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE); + String ldapAttrName = mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE); + + // Add mapped attribute to returning ldap attributes + query.addReturningLdapAttribute(ldapAttrName); + if (isReadOnly()) { + query.addReturningReadOnlyLdapAttribute(ldapAttrName); + } + + // Change conditions and use ldapAttribute instead of userModel + for (Condition condition : query.getConditions()) { + condition.updateParameterName(userModelAttrName, ldapAttrName); + } + } + + private boolean isReadOnly() { + return parseBooleanParameter(mapperModel, READ_ONLY); + } + + + protected void setPropertyOnUserModel(Property userModelProperty, UserModel user, String ldapAttrValue) { + if (ldapAttrValue == null) { + userModelProperty.setValue(user, null); + } else { + Class clazz = userModelProperty.getJavaClass(); + + if (String.class.equals(clazz)) { + userModelProperty.setValue(user, ldapAttrValue); + } else if (Boolean.class.equals(clazz) || boolean.class.equals(clazz)) { + Boolean boolVal = Boolean.valueOf(ldapAttrValue); + userModelProperty.setValue(user, boolVal); + } else { + logger.warnf("Don't know how to set the property '%s' on user '%s' . Value of LDAP attribute is '%s' ", userModelProperty.getName(), user.getUsername(), ldapAttrValue.toString()); + } + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapperFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapperFactory.java new file mode 100755 index 0000000000..99d9ea1b2f --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapperFactory.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.storage.ldap.mappers; + +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserFederationProvider; +import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Marek Posolda + */ +public class UserAttributeLDAPStorageMapperFactory extends AbstractLDAPStorageMapperFactory { + + public static final String PROVIDER_ID = "user-attribute-ldap-mapper"; + protected static final List configProperties; + + static { + List props = getConfigProps(null); + configProperties = props; + } + + private static List getConfigProps(ComponentModel parent) { + String readOnly = "false"; + if (parent != null) { + LDAPConfig ldapConfig = new LDAPConfig(parent.getConfig()); + readOnly = ldapConfig.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE ? "false" : "true"; + } + return ProviderConfigurationBuilder.create() + .property().name(UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE) + .label("User Model Attribute") + .helpText("Name of mapped UserModel property or UserModel attribute in Keycloak DB. For example 'firstName', 'lastName, 'email', 'street' etc.") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property().name(UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE).label("LDAP Attribute").helpText("Name of mapped attribute on LDAP object. For example 'cn', 'sn, 'mail', 'street' etc.") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property().name(UserAttributeLDAPStorageMapper.READ_ONLY).label("Read Only") + .helpText("Read-only attribute is imported from LDAP to Keycloak DB, but it's not saved back to LDAP when user is updated in Keycloak.") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(readOnly) + .add() + .property().name(UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP).label("Always Read Value From LDAP") + .helpText("If on, then during reading of the user will be value of attribute from LDAP always used instead of the value from Keycloak DB") + .type(ProviderConfigProperty.BOOLEAN_TYPE).defaultValue("false").add() + .property().name(UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP).label("Is Mandatory In LDAP") + .helpText("If true, attribute is mandatory in LDAP. Hence if there is no value in Keycloak DB, the empty value will be set to be propagated to LDAP") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue("false").add() + .build(); + } + + @Override + public String getHelpText() { + return "Used to map single attribute from LDAP user to attribute of UserModel in Keycloak DB"; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { + checkMandatoryConfigAttribute(UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, "User Model Attribute", config); + checkMandatoryConfigAttribute(UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, "LDAP Attribute", config); + + } + + @Override + protected AbstractLDAPStorageMapper createMapper(ComponentModel mapperModel, LDAPStorageProvider federationProvider, RealmModel realm) { + return new UserAttributeLDAPStorageMapper(mapperModel, federationProvider, realm); + } + + @Override + public List getConfigProperties(RealmModel realm, ComponentModel parent) { + return getConfigProps(parent); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/CommonLDAPGroupMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/CommonLDAPGroupMapper.java new file mode 100644 index 0000000000..018b0e34d4 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/CommonLDAPGroupMapper.java @@ -0,0 +1,32 @@ +/* + * 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.storage.ldap.mappers.membership; + +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; + +/** + * Mapper related to mapping of LDAP groups to keycloak model objects (either keycloak roles or keycloak groups) + * + * @author Marek Posolda + */ +public interface CommonLDAPGroupMapper { + + LDAPQuery createLDAPGroupQuery(); + + CommonLDAPGroupMapperConfig getConfig(); +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/CommonLDAPGroupMapperConfig.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/CommonLDAPGroupMapperConfig.java new file mode 100644 index 0000000000..a4ff1756b4 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/CommonLDAPGroupMapperConfig.java @@ -0,0 +1,87 @@ +/* + * 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.storage.ldap.mappers.membership; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; + +import java.util.HashSet; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public abstract class CommonLDAPGroupMapperConfig { + + // Name of LDAP attribute on role, which is used for membership mappings. Usually it will be "member" + public static final String MEMBERSHIP_LDAP_ATTRIBUTE = "membership.ldap.attribute"; + + // See docs for MembershipType enum + public static final String MEMBERSHIP_ATTRIBUTE_TYPE = "membership.attribute.type"; + + // See docs for Mode enum + public static final String MODE = "mode"; + + // See docs for UserRolesRetriever enum + public static final String USER_ROLES_RETRIEVE_STRATEGY = "user.roles.retrieve.strategy"; + + + protected final ComponentModel mapperModel; + + public CommonLDAPGroupMapperConfig(ComponentModel mapperModel) { + this.mapperModel = mapperModel; + } + + public String getMembershipLdapAttribute() { + String membershipAttrName = mapperModel.getConfig().getFirst(MEMBERSHIP_LDAP_ATTRIBUTE); + return membershipAttrName!=null ? membershipAttrName : LDAPConstants.MEMBER; + } + + public MembershipType getMembershipTypeLdapAttribute() { + String membershipType = mapperModel.getConfig().getFirst(MEMBERSHIP_ATTRIBUTE_TYPE); + return (membershipType!=null && !membershipType.isEmpty()) ? Enum.valueOf(MembershipType.class, membershipType) : MembershipType.DN; + } + + public LDAPGroupMapperMode getMode() { + String modeString = mapperModel.getConfig().getFirst(MODE); + if (modeString == null || modeString.isEmpty()) { + throw new ModelException("Mode is missing! Check your configuration"); + } + + return Enum.valueOf(LDAPGroupMapperMode.class, modeString.toUpperCase()); + } + + protected Set getConfigValues(String str) { + String[] objClasses = str.split(","); + Set trimmed = new HashSet<>(); + for (String objectClass : objClasses) { + objectClass = objectClass.trim(); + if (objectClass.length() > 0) { + trimmed.add(objectClass); + } + } + return trimmed; + } + + public abstract String getLDAPGroupsDn(); + + public abstract String getLDAPGroupNameLdapAttribute(); + + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/LDAPGroupMapperMode.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/LDAPGroupMapperMode.java new file mode 100644 index 0000000000..b048c220b7 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/LDAPGroupMapperMode.java @@ -0,0 +1,46 @@ +/* + * 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.storage.ldap.mappers.membership; + +/** + * @author Marek Posolda + */ +public enum LDAPGroupMapperMode { + + /** + * All role mappings are retrieved from LDAP and saved into LDAP + */ + LDAP_ONLY, + + /** + * Read-only LDAP mode. Role mappings are retrieved from LDAP for particular user just at the time when he is imported and then + * they are saved to local keycloak DB. Then all role mappings are always retrieved from keycloak DB, never from LDAP. + * Creating or deleting of role mapping is propagated only to DB. + * + * This is read-only mode LDAP mode and it's good for performance, but when user is put to some role directly in LDAP, it + * won't be seen by Keycloak + */ + IMPORT, + + /** + * Read-only LDAP mode. Role mappings are retrieved from both LDAP and DB and merged together. New role grants are not saved to LDAP but to DB. + * Deleting role mappings, which is mapped to LDAP, will throw an error. + */ + READ_ONLY + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/MembershipType.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/MembershipType.java new file mode 100644 index 0000000000..f62e40eff7 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/MembershipType.java @@ -0,0 +1,157 @@ +/* + * 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.storage.ldap.mappers.membership; + +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPUtils; +import org.keycloak.storage.ldap.idm.model.LDAPDn; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +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.mappers.membership.group.GroupLDAPStorageMapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public enum MembershipType { + + /** + * Used if LDAP role has it's members declared in form of their full DN. For example ( "member: uid=john,ou=users,dc=example,dc=com" ) + */ + DN { + + @Override + public Set getLDAPSubgroups(GroupLDAPStorageMapper groupMapper, LDAPObject ldapGroup) { + CommonLDAPGroupMapperConfig config = groupMapper.getConfig(); + return getLDAPMembersWithParent(ldapGroup, config.getMembershipLdapAttribute(), LDAPDn.fromString(config.getLDAPGroupsDn())); + } + + // Get just those members of specified group, which are descendants of "requiredParentDn" + protected Set getLDAPMembersWithParent(LDAPObject ldapGroup, String membershipLdapAttribute, LDAPDn requiredParentDn) { + Set allMemberships = LDAPUtils.getExistingMemberships(membershipLdapAttribute, ldapGroup); + + // Filter and keep just groups + Set result = new HashSet<>(); + for (String membership : allMemberships) { + LDAPDn childDn = LDAPDn.fromString(membership); + if (childDn.isDescendantOf(requiredParentDn)) { + result.add(childDn); + } + } + return result; + } + + @Override + public List getGroupMembers(GroupLDAPStorageMapper groupMapper, LDAPObject ldapGroup, int firstResult, int maxResults) { + RealmModel realm = groupMapper.getRealm(); + LDAPStorageProvider ldapProvider = groupMapper.getLdapProvider(); + CommonLDAPGroupMapperConfig config = groupMapper.getConfig(); + + LDAPDn usersDn = LDAPDn.fromString(ldapProvider.getLdapIdentityStore().getConfig().getUsersDn()); + Set userDns = getLDAPMembersWithParent(ldapGroup, config.getMembershipLdapAttribute(), usersDn); + + if (userDns == null) { + return Collections.emptyList(); + } + + if (userDns.size() <= firstResult) { + return Collections.emptyList(); + } + + List dns = new ArrayList<>(userDns); + int max = Math.min(dns.size(), firstResult + maxResults); + dns = dns.subList(firstResult, max); + + // If usernameAttrName is same like DN, we can just retrieve usernames from DNs + List usernames = new LinkedList<>(); + LDAPConfig ldapConfig = ldapProvider.getLdapIdentityStore().getConfig(); + if (ldapConfig.getUsernameLdapAttribute().equals(ldapConfig.getRdnLdapAttribute())) { + for (LDAPDn userDn : dns) { + String username = userDn.getFirstRdnAttrValue(); + usernames.add(username); + } + } else { + LDAPQuery query = LDAPUtils.createQueryForUserSearch(ldapProvider, realm); + LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder(); + Condition[] orSubconditions = new Condition[dns.size()]; + int index = 0; + for (LDAPDn userDn : dns) { + Condition condition = conditionsBuilder.equal(userDn.getFirstRdnAttrName(), userDn.getFirstRdnAttrValue()); + orSubconditions[index] = condition; + index++; + } + Condition orCondition = conditionsBuilder.orCondition(orSubconditions); + query.addWhereCondition(orCondition); + List ldapUsers = query.getResultList(); + for (LDAPObject ldapUser : ldapUsers) { + String username = LDAPUtils.getUsername(ldapUser, ldapConfig); + usernames.add(username); + } + } + + // We have dns of users, who are members of our group. Load them now + return ldapProvider.loadUsersByUsernames(usernames, realm); + } + + }, + + + /** + * Used if LDAP role has it's members declared in form of pure user uids. For example ( "memberUid: john" ) + */ + UID { + + // Group inheritance not supported for this config + @Override + public Set getLDAPSubgroups(GroupLDAPStorageMapper groupMapper, LDAPObject ldapGroup) { + return Collections.emptySet(); + } + + @Override + public List getGroupMembers(GroupLDAPStorageMapper groupMapper, LDAPObject ldapGroup, int firstResult, int maxResults) { + String memberAttrName = groupMapper.getConfig().getMembershipLdapAttribute(); + Set memberUids = LDAPUtils.getExistingMemberships(memberAttrName, ldapGroup); + + if (memberUids == null || memberUids.size() <= firstResult) { + return Collections.emptyList(); + } + + List uids = new ArrayList<>(memberUids); + int max = Math.min(memberUids.size(), firstResult + maxResults); + uids = uids.subList(firstResult, max); + + return groupMapper.getLdapProvider().loadUsersByUsernames(uids, groupMapper.getRealm()); + } + + }; + + public abstract Set getLDAPSubgroups(GroupLDAPStorageMapper groupMapper, LDAPObject ldapGroup); + + public abstract List getGroupMembers(GroupLDAPStorageMapper groupMapper, LDAPObject ldapGroup, int firstResult, int maxResults); +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/UserRolesRetrieveStrategy.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/UserRolesRetrieveStrategy.java new file mode 100644 index 0000000000..29dd1d4243 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/UserRolesRetrieveStrategy.java @@ -0,0 +1,128 @@ +/* + * 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.storage.ldap.mappers.membership; + + +import org.keycloak.models.LDAPConstants; +import org.keycloak.storage.ldap.LDAPUtils; +import org.keycloak.storage.ldap.idm.model.LDAPDn; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +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 java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +/** + * Strategy for how to retrieve LDAP roles of user + * + * @author Marek Posolda + */ +public interface UserRolesRetrieveStrategy { + + + List getLDAPRoleMappings(CommonLDAPGroupMapper roleOrGroupMapper, LDAPObject ldapUser); + + void beforeUserLDAPQuery(LDAPQuery query); + + + // Impl subclasses + + /** + * Roles of user will be retrieved by sending LDAP query to retrieve all roles where "member" is our user + */ + class LoadRolesByMember implements UserRolesRetrieveStrategy { + + @Override + public List getLDAPRoleMappings(CommonLDAPGroupMapper roleOrGroupMapper, LDAPObject ldapUser) { + LDAPQuery ldapQuery = roleOrGroupMapper.createLDAPGroupQuery(); + String membershipAttr = roleOrGroupMapper.getConfig().getMembershipLdapAttribute(); + + String userMembership = LDAPUtils.getMemberValueOfChildObject(ldapUser, roleOrGroupMapper.getConfig().getMembershipTypeLdapAttribute()); + + Condition membershipCondition = getMembershipCondition(membershipAttr, userMembership); + ldapQuery.addWhereCondition(membershipCondition); + return ldapQuery.getResultList(); + } + + @Override + public void beforeUserLDAPQuery(LDAPQuery query) { + } + + protected Condition getMembershipCondition(String membershipAttr, String userMembership) { + return new LDAPQueryConditionsBuilder().equal(membershipAttr, userMembership); + } + + }; + + /** + * Roles of user will be retrieved from "memberOf" attribute of our user + */ + class GetRolesFromUserMemberOfAttribute implements UserRolesRetrieveStrategy { + + @Override + public List getLDAPRoleMappings(CommonLDAPGroupMapper roleOrGroupMapper, LDAPObject ldapUser) { + Set memberOfValues = ldapUser.getAttributeAsSet(LDAPConstants.MEMBER_OF); + if (memberOfValues == null) { + return Collections.emptyList(); + } + + List roles = new LinkedList<>(); + LDAPDn parentDn = LDAPDn.fromString(roleOrGroupMapper.getConfig().getLDAPGroupsDn()); + + for (String roleDn : memberOfValues) { + LDAPDn roleDN = LDAPDn.fromString(roleDn); + if (roleDN.isDescendantOf(parentDn)) { + LDAPObject role = new LDAPObject(); + role.setDn(roleDN); + + String firstDN = roleDN.getFirstRdnAttrName(); + if (firstDN.equalsIgnoreCase(roleOrGroupMapper.getConfig().getLDAPGroupNameLdapAttribute())) { + role.setRdnAttributeName(firstDN); + role.setSingleAttribute(firstDN, roleDN.getFirstRdnAttrValue()); + roles.add(role); + } + } + } + return roles; + } + + @Override + public void beforeUserLDAPQuery(LDAPQuery query) { + query.addReturningLdapAttribute(LDAPConstants.MEMBER_OF); + query.addReturningReadOnlyLdapAttribute(LDAPConstants.MEMBER_OF); + } + + }; + + /** + * Extension specific to Active Directory. Roles of user will be retrieved by sending LDAP query to retrieve all roles where "member" is our user. + * The query will be able to retrieve memberships recursively with usage of AD specific extension LDAP_MATCHING_RULE_IN_CHAIN, so likely doesn't work on other LDAP servers + */ + class LoadRolesByMemberRecursively extends LoadRolesByMember { + + protected Condition getMembershipCondition(String membershipAttr, String userMembership) { + return new LDAPQueryConditionsBuilder().equal(membershipAttr + LDAPConstants.LDAP_MATCHING_RULE_IN_CHAIN, userMembership); + } + + }; + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupLDAPStorageMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupLDAPStorageMapper.java new file mode 100644 index 0000000000..9a53a41b20 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupLDAPStorageMapper.java @@ -0,0 +1,648 @@ +/* + * 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.storage.ldap.mappers.membership.group; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPUtils; +import org.keycloak.storage.ldap.idm.model.LDAPDn; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +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.mappers.AbstractLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.membership.CommonLDAPGroupMapper; +import org.keycloak.storage.ldap.mappers.membership.CommonLDAPGroupMapperConfig; +import org.keycloak.storage.ldap.mappers.membership.LDAPGroupMapperMode; +import org.keycloak.storage.ldap.mappers.membership.MembershipType; +import org.keycloak.storage.ldap.mappers.membership.UserRolesRetrieveStrategy; +import org.keycloak.storage.user.SynchronizationResult; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements CommonLDAPGroupMapper { + + private static final Logger logger = Logger.getLogger(GroupLDAPStorageMapper.class); + + private final GroupMapperConfig config; + private final GroupLDAPStorageMapperFactory factory; + + // Flag to avoid syncing multiple times per transaction + private boolean syncFromLDAPPerformedInThisTransaction = false; + + public GroupLDAPStorageMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, RealmModel realm, GroupLDAPStorageMapperFactory factory) { + super(mapperModel, ldapProvider, realm); + this.config = new GroupMapperConfig(mapperModel); + this.factory = factory; + } + + + // CommonLDAPGroupMapper interface + + @Override + public LDAPQuery createLDAPGroupQuery() { + return createGroupQuery(); + } + + @Override + public CommonLDAPGroupMapperConfig getConfig() { + return config; + } + + + + // LDAP Group CRUD operations + + public LDAPQuery createGroupQuery() { + LDAPQuery ldapQuery = new LDAPQuery(ldapProvider); + + // For now, use same search scope, which is configured "globally" and used for user's search. + ldapQuery.setSearchScope(ldapProvider.getLdapIdentityStore().getConfig().getSearchScope()); + + String groupsDn = config.getGroupsDn(); + ldapQuery.setSearchDn(groupsDn); + + Collection groupObjectClasses = config.getGroupObjectClasses(ldapProvider); + ldapQuery.addObjectClasses(groupObjectClasses); + + String customFilter = config.getCustomLdapFilter(); + if (customFilter != null && customFilter.trim().length() > 0) { + Condition customFilterCondition = new LDAPQueryConditionsBuilder().addCustomLDAPFilter(customFilter); + ldapQuery.addWhereCondition(customFilterCondition); + } + + ldapQuery.addReturningLdapAttribute(config.getGroupNameLdapAttribute()); + ldapQuery.addReturningLdapAttribute(config.getMembershipLdapAttribute()); + + for (String groupAttr : config.getGroupAttributes()) { + ldapQuery.addReturningLdapAttribute(groupAttr); + } + + return ldapQuery; + } + + public LDAPObject createLDAPGroup(String groupName, Map> additionalAttributes) { + LDAPObject ldapGroup = LDAPUtils.createLDAPGroup(ldapProvider, groupName, config.getGroupNameLdapAttribute(), config.getGroupObjectClasses(ldapProvider), + config.getGroupsDn(), additionalAttributes); + + logger.debugf("Creating group [%s] to LDAP with DN [%s]", groupName, ldapGroup.getDn().toString()); + return ldapGroup; + } + + public LDAPObject loadLDAPGroupByName(String groupName) { + LDAPQuery ldapQuery = createGroupQuery(); + Condition roleNameCondition = new LDAPQueryConditionsBuilder().equal(config.getGroupNameLdapAttribute(), groupName); + ldapQuery.addWhereCondition(roleNameCondition); + return ldapQuery.getFirstResult(); + } + + protected Set getLDAPSubgroups(LDAPObject ldapGroup) { + MembershipType membershipType = config.getMembershipTypeLdapAttribute(); + return membershipType.getLDAPSubgroups(this, ldapGroup); + } + + + // Sync from Ldap to KC + + @Override + public SynchronizationResult syncDataFromFederationProviderToKeycloak() { + SynchronizationResult syncResult = new SynchronizationResult() { + + @Override + public String getStatus() { + return String.format("%d imported groups, %d updated groups, %d removed groups", getAdded(), getUpdated(), getRemoved()); + } + + }; + + logger.debugf("Syncing groups from LDAP into Keycloak DB. Mapper is [%s], LDAP provider is [%s]", mapperModel.getName(), ldapProvider.getModel().getName()); + + // Get all LDAP groups + List ldapGroups = getAllLDAPGroups(); + + // Convert to internal format + Map ldapGroupsMap = new HashMap<>(); + List ldapGroupsRep = new LinkedList<>(); + + String groupsRdnAttr = config.getGroupNameLdapAttribute(); + for (LDAPObject ldapGroup : ldapGroups) { + String groupName = ldapGroup.getAttributeAsString(groupsRdnAttr); + + Set subgroupNames = new HashSet<>(); + for (LDAPDn groupDn : getLDAPSubgroups(ldapGroup)) { + subgroupNames.add(groupDn.getFirstRdnAttrValue()); + } + + ldapGroupsRep.add(new GroupTreeResolver.Group(groupName, subgroupNames)); + ldapGroupsMap.put(groupName, ldapGroup); + } + + // Now we have list of LDAP groups. Let's form the tree (if needed) + if (config.isPreserveGroupsInheritance()) { + try { + List groupTrees = new GroupTreeResolver().resolveGroupTree(ldapGroupsRep); + + updateKeycloakGroupTree(groupTrees, ldapGroupsMap, syncResult); + } catch (GroupTreeResolver.GroupTreeResolveException gre) { + throw new ModelException("Couldn't resolve groups from LDAP. Fix LDAP or skip preserve inheritance. Details: " + gre.getMessage(), gre); + } + } else { + Set visitedGroupIds = new HashSet<>(); + + // Just add flat structure of groups with all groups at top-level + for (Map.Entry groupEntry : ldapGroupsMap.entrySet()) { + String groupName = groupEntry.getKey(); + GroupModel kcExistingGroup = KeycloakModelUtils.findGroupByPath(realm, "/" + groupName); + + if (kcExistingGroup != null) { + updateAttributesOfKCGroup(kcExistingGroup, groupEntry.getValue()); + syncResult.increaseUpdated(); + visitedGroupIds.add(kcExistingGroup.getId()); + } else { + GroupModel kcGroup = realm.createGroup(groupName); + updateAttributesOfKCGroup(kcGroup, groupEntry.getValue()); + realm.moveGroup(kcGroup, null); + syncResult.increaseAdded(); + visitedGroupIds.add(kcGroup.getId()); + } + } + + // Possibly remove keycloak groups, which doesn't exists in LDAP + if (config.isDropNonExistingGroupsDuringSync()) { + dropNonExistingKcGroups(syncResult, visitedGroupIds); + } + } + + syncFromLDAPPerformedInThisTransaction = true; + + return syncResult; + } + + private void updateKeycloakGroupTree(List groupTrees, Map ldapGroups, SynchronizationResult syncResult) { + Set visitedGroupIds = new HashSet<>(); + + for (GroupTreeResolver.GroupTreeEntry groupEntry : groupTrees) { + updateKeycloakGroupTreeEntry(groupEntry, ldapGroups, null, syncResult, visitedGroupIds); + } + + // Possibly remove keycloak groups, which doesn't exists in LDAP + if (config.isDropNonExistingGroupsDuringSync()) { + dropNonExistingKcGroups(syncResult, visitedGroupIds); + } + } + + private void updateKeycloakGroupTreeEntry(GroupTreeResolver.GroupTreeEntry groupTreeEntry, Map ldapGroups, GroupModel kcParent, SynchronizationResult syncResult, Set visitedGroupIds) { + String groupName = groupTreeEntry.getGroupName(); + + // Check if group already exists + GroupModel kcGroup = null; + Collection subgroups = kcParent == null ? realm.getTopLevelGroups() : kcParent.getSubGroups(); + for (GroupModel group : subgroups) { + if (group.getName().equals(groupName)) { + kcGroup = group; + break; + } + } + + if (kcGroup != null) { + logger.debugf("Updated Keycloak group '%s' from LDAP", kcGroup.getName()); + updateAttributesOfKCGroup(kcGroup, ldapGroups.get(kcGroup.getName())); + syncResult.increaseUpdated(); + } else { + kcGroup = realm.createGroup(groupTreeEntry.getGroupName()); + if (kcParent == null) { + realm.moveGroup(kcGroup, null); + logger.debugf("Imported top-level group '%s' from LDAP", kcGroup.getName()); + } else { + realm.moveGroup(kcGroup, kcParent); + logger.debugf("Imported group '%s' from LDAP as child of group '%s'", kcGroup.getName(), kcParent.getName()); + } + + updateAttributesOfKCGroup(kcGroup, ldapGroups.get(kcGroup.getName())); + syncResult.increaseAdded(); + } + + visitedGroupIds.add(kcGroup.getId()); + + for (GroupTreeResolver.GroupTreeEntry childEntry : groupTreeEntry.getChildren()) { + updateKeycloakGroupTreeEntry(childEntry, ldapGroups, kcGroup, syncResult, visitedGroupIds); + } + } + + private void dropNonExistingKcGroups(SynchronizationResult syncResult, Set visitedGroupIds) { + // Remove keycloak groups, which doesn't exists in LDAP + List allGroups = realm.getGroups(); + for (GroupModel kcGroup : allGroups) { + if (!visitedGroupIds.contains(kcGroup.getId())) { + logger.debugf("Removing Keycloak group '%s', which doesn't exist in LDAP", kcGroup.getName()); + realm.removeGroup(kcGroup); + syncResult.increaseRemoved(); + } + } + } + + private void updateAttributesOfKCGroup(GroupModel kcGroup, LDAPObject ldapGroup) { + Collection groupAttributes = config.getGroupAttributes(); + + for (String attrName : groupAttributes) { + Set attrValues = ldapGroup.getAttributeAsSet(attrName); + if (attrValues==null) { + kcGroup.removeAttribute(attrName); + } else { + kcGroup.setAttribute(attrName, new LinkedList<>(attrValues)); + } + } + } + + + protected GroupModel findKcGroupByLDAPGroup(LDAPObject ldapGroup) { + String groupNameAttr = config.getGroupNameLdapAttribute(); + String groupName = ldapGroup.getAttributeAsString(groupNameAttr); + + if (config.isPreserveGroupsInheritance()) { + // Override if better effectivity or different algorithm is needed + List groups = realm.getGroups(); + for (GroupModel group : groups) { + if (group.getName().equals(groupName)) { + return group; + } + } + + return null; + } else { + // Without preserved inheritance, it's always top-level group + return KeycloakModelUtils.findGroupByPath(realm, "/" + groupName); + } + } + + protected GroupModel findKcGroupOrSyncFromLDAP(LDAPObject ldapGroup, UserModel user) { + GroupModel kcGroup = findKcGroupByLDAPGroup(ldapGroup); + + if (kcGroup == null) { + + if (config.isPreserveGroupsInheritance()) { + + // Better to sync all groups from LDAP with preserved inheritance + if (!syncFromLDAPPerformedInThisTransaction) { + syncDataFromFederationProviderToKeycloak(); + kcGroup = findKcGroupByLDAPGroup(ldapGroup); + } + } else { + String groupNameAttr = config.getGroupNameLdapAttribute(); + String groupName = ldapGroup.getAttributeAsString(groupNameAttr); + + kcGroup = realm.createGroup(groupName); + updateAttributesOfKCGroup(kcGroup, ldapGroup); + realm.moveGroup(kcGroup, null); + } + + // Could theoretically happen on some LDAP servers if 'memberof' style is used and 'memberof' attribute of user references non-existing group + if (kcGroup == null) { + String groupName = ldapGroup.getAttributeAsString(config.getGroupNameLdapAttribute()); + logger.warnf("User '%s' is member of group '%s', which doesn't exists in LDAP", user.getUsername(), groupName); + } + } + + return kcGroup; + } + + // Send LDAP query to retrieve all groups + protected List getAllLDAPGroups() { + LDAPQuery ldapGroupQuery = createGroupQuery(); + return LDAPUtils.loadAllLDAPObjects(ldapGroupQuery, ldapProvider); + } + + + // Sync from Keycloak to LDAP + + public SynchronizationResult syncDataFromKeycloakToFederationProvider() { + SynchronizationResult syncResult = new SynchronizationResult() { + + @Override + public String getStatus() { + return String.format("%d groups imported to LDAP, %d groups updated to LDAP, %d groups removed from LDAP", getAdded(), getUpdated(), getRemoved()); + } + + }; + + if (config.getMode() != LDAPGroupMapperMode.LDAP_ONLY) { + logger.warnf("Ignored sync for federation mapper '%s' as it's mode is '%s'", mapperModel.getName(), config.getMode().toString()); + return syncResult; + } + + logger.debugf("Syncing groups from Keycloak into LDAP. Mapper is [%s], LDAP provider is [%s]", mapperModel.getName(), ldapProvider.getModel().getName()); + + // Query existing LDAP groups + LDAPQuery ldapQuery = createGroupQuery(); + List ldapGroups = ldapQuery.getResultList(); + + // Convert them to Map + Map ldapGroupsMap = new HashMap<>(); + String groupsRdnAttr = config.getGroupNameLdapAttribute(); + for (LDAPObject ldapGroup : ldapGroups) { + String groupName = ldapGroup.getAttributeAsString(groupsRdnAttr); + ldapGroupsMap.put(groupName, ldapGroup); + } + + // Map to track all LDAP groups also exists in Keycloak + Set ldapGroupNames = new HashSet<>(); + + // Create or update KC groups to LDAP including their attributes + for (GroupModel kcGroup : realm.getTopLevelGroups()) { + processLdapGroupSyncToLDAP(kcGroup, ldapGroupsMap, ldapGroupNames, syncResult); + } + + // If dropNonExisting, then drop all groups, which doesn't exist in KC from LDAP as well + if (config.isDropNonExistingGroupsDuringSync()) { + Set copy = new HashSet<>(ldapGroupsMap.keySet()); + for (String groupName : copy) { + if (!ldapGroupNames.contains(groupName)) { + LDAPObject ldapGroup = ldapGroupsMap.remove(groupName); + ldapProvider.getLdapIdentityStore().remove(ldapGroup); + syncResult.increaseRemoved(); + } + } + } + + // Finally process memberships, + if (config.isPreserveGroupsInheritance()) { + for (GroupModel kcGroup : realm.getTopLevelGroups()) { + processLdapGroupMembershipsSyncToLDAP(kcGroup, ldapGroupsMap); + } + } + + return syncResult; + } + + // For given kcGroup check if it exists in LDAP (map) by name + // If not, create it in LDAP including attributes. Otherwise update attributes in LDAP. + // Process this recursively for all subgroups of KC group + private void processLdapGroupSyncToLDAP(GroupModel kcGroup, Map ldapGroupsMap, Set ldapGroupNames, SynchronizationResult syncResult) { + String groupName = kcGroup.getName(); + + // extract group attributes to be updated to LDAP + Map> supportedLdapAttributes = new HashMap<>(); + for (String attrName : config.getGroupAttributes()) { + List kcAttrValues = kcGroup.getAttribute(attrName); + Set attrValues2 = (kcAttrValues == null || kcAttrValues.isEmpty()) ? null : new HashSet<>(kcAttrValues); + supportedLdapAttributes.put(attrName, attrValues2); + } + + LDAPObject ldapGroup = ldapGroupsMap.get(groupName); + + if (ldapGroup == null) { + ldapGroup = createLDAPGroup(groupName, supportedLdapAttributes); + syncResult.increaseAdded(); + } else { + for (Map.Entry> attrEntry : supportedLdapAttributes.entrySet()) { + ldapGroup.setAttribute(attrEntry.getKey(), attrEntry.getValue()); + } + + ldapProvider.getLdapIdentityStore().update(ldapGroup); + syncResult.increaseUpdated(); + } + + ldapGroupsMap.put(groupName, ldapGroup); + ldapGroupNames.add(groupName); + + // process KC subgroups + for (GroupModel kcSubgroup : kcGroup.getSubGroups()) { + processLdapGroupSyncToLDAP(kcSubgroup, ldapGroupsMap, ldapGroupNames, syncResult); + } + } + + // Sync memberships update. Update memberships of group in LDAP based on subgroups from KC. Do it recursively + private void processLdapGroupMembershipsSyncToLDAP(GroupModel kcGroup, Map ldapGroupsMap) { + LDAPObject ldapGroup = ldapGroupsMap.get(kcGroup.getName()); + Set toRemoveSubgroupsDNs = getLDAPSubgroups(ldapGroup); + + // Add LDAP subgroups, which are KC subgroups + Set kcSubgroups = kcGroup.getSubGroups(); + for (GroupModel kcSubgroup : kcSubgroups) { + LDAPObject ldapSubgroup = ldapGroupsMap.get(kcSubgroup.getName()); + LDAPUtils.addMember(ldapProvider, MembershipType.DN, config.getMembershipLdapAttribute(), ldapGroup, ldapSubgroup, false); + toRemoveSubgroupsDNs.remove(ldapSubgroup.getDn()); + } + + // Remove LDAP subgroups, which are not members in KC anymore + for (LDAPDn toRemoveDN : toRemoveSubgroupsDNs) { + LDAPObject fakeGroup = new LDAPObject(); + fakeGroup.setDn(toRemoveDN); + LDAPUtils.deleteMember(ldapProvider, MembershipType.DN, config.getMembershipLdapAttribute(), ldapGroup, fakeGroup, false); + } + + // Update group to LDAP + if (!kcGroup.getSubGroups().isEmpty() || !toRemoveSubgroupsDNs.isEmpty()) { + ldapProvider.getLdapIdentityStore().update(ldapGroup); + } + + for (GroupModel kcSubgroup : kcGroup.getSubGroups()) { + processLdapGroupMembershipsSyncToLDAP(kcSubgroup, ldapGroupsMap); + } + } + + + // group-user membership operations + + + @Override + public List getGroupMembers(GroupModel kcGroup, int firstResult, int maxResults) { + LDAPObject ldapGroup = loadLDAPGroupByName(kcGroup.getName()); + if (ldapGroup == null) { + return Collections.emptyList(); + } + + MembershipType membershipType = config.getMembershipTypeLdapAttribute(); + return membershipType.getGroupMembers(this, ldapGroup, firstResult, maxResults); + } + + public void addGroupMappingInLDAP(String groupName, LDAPObject ldapUser) { + LDAPObject ldapGroup = loadLDAPGroupByName(groupName); + if (ldapGroup == null) { + syncDataFromKeycloakToFederationProvider(); + ldapGroup = loadLDAPGroupByName(groupName); + } + + LDAPUtils.addMember(ldapProvider, config.getMembershipTypeLdapAttribute(), config.getMembershipLdapAttribute(), ldapGroup, ldapUser, true); + } + + public void deleteGroupMappingInLDAP(LDAPObject ldapUser, LDAPObject ldapGroup) { + LDAPUtils.deleteMember(ldapProvider, config.getMembershipTypeLdapAttribute(), config.getMembershipLdapAttribute(), ldapGroup, ldapUser, true); + } + + protected List getLDAPGroupMappings(LDAPObject ldapUser) { + String strategyKey = config.getUserGroupsRetrieveStrategy(); + UserRolesRetrieveStrategy strategy = factory.getUserGroupsRetrieveStrategy(strategyKey); + return strategy.getLDAPRoleMappings(this, ldapUser); + } + + public void beforeLDAPQuery(LDAPQuery query) { + String strategyKey = config.getUserGroupsRetrieveStrategy(); + UserRolesRetrieveStrategy strategy = factory.getUserGroupsRetrieveStrategy(strategyKey); + strategy.beforeUserLDAPQuery(query); + } + + public UserModel proxy(LDAPObject ldapUser, UserModel delegate) { + final LDAPGroupMapperMode mode = config.getMode(); + + // For IMPORT mode, all operations are performed against local DB + if (mode == LDAPGroupMapperMode.IMPORT) { + return delegate; + } else { + return new LDAPGroupMappingsUserDelegate(delegate, ldapUser); + } + } + + public void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser) { + } + + public void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, boolean isCreate) { + LDAPGroupMapperMode mode = config.getMode(); + + // For now, import LDAP group mappings just during create + if (mode == LDAPGroupMapperMode.IMPORT && isCreate) { + + List ldapGroups = getLDAPGroupMappings(ldapUser); + + // Import role mappings from LDAP into Keycloak DB + for (LDAPObject ldapGroup : ldapGroups) { + + GroupModel kcGroup = findKcGroupOrSyncFromLDAP(ldapGroup, user); + if (kcGroup != null) { + logger.debugf("User '%s' joins group '%s' during import from LDAP", user.getUsername(), kcGroup.getName()); + user.joinGroup(kcGroup); + } + } + } + } + + + public class LDAPGroupMappingsUserDelegate extends UserModelDelegate { + + private final LDAPObject ldapUser; + + // Avoid loading group mappings from LDAP more times per-request + private Set cachedLDAPGroupMappings; + + public LDAPGroupMappingsUserDelegate(UserModel user, LDAPObject ldapUser) { + super(user); + this.ldapUser = ldapUser; + } + + @Override + public boolean hasRole(RoleModel role) { + return super.hasRole(role) || KeycloakModelUtils.hasRoleFromGroup(getGroups(), role, true); + } + + @Override + public Set getGroups() { + Set ldapGroupMappings = getLDAPGroupMappingsConverted(); + if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) { + // Use just group mappings from LDAP + return ldapGroupMappings; + } else { + // Merge mappings from both DB and LDAP + Set modelGroupMappings = super.getGroups(); + ldapGroupMappings.addAll(modelGroupMappings); + return ldapGroupMappings; + } + } + + @Override + public void joinGroup(GroupModel group) { + if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) { + // We need to create new role mappings in LDAP + cachedLDAPGroupMappings = null; + addGroupMappingInLDAP(group.getName(), ldapUser); + } else { + super.joinGroup(group); + } + } + + @Override + public void leaveGroup(GroupModel group) { + LDAPQuery ldapQuery = createGroupQuery(); + LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder(); + Condition roleNameCondition = conditionsBuilder.equal(config.getGroupNameLdapAttribute(), group.getName()); + String membershipUserAttr = LDAPUtils.getMemberValueOfChildObject(ldapUser, config.getMembershipTypeLdapAttribute()); + Condition membershipCondition = conditionsBuilder.equal(config.getMembershipLdapAttribute(), membershipUserAttr); + ldapQuery.addWhereCondition(roleNameCondition).addWhereCondition(membershipCondition); + LDAPObject ldapGroup = ldapQuery.getFirstResult(); + + if (ldapGroup == null) { + // Group mapping doesn't exist in LDAP. For LDAP_ONLY mode, we don't need to do anything. For READ_ONLY, delete it in local DB. + if (config.getMode() == LDAPGroupMapperMode.READ_ONLY) { + super.leaveGroup(group); + } + } else { + // Group mappings exists in LDAP. For LDAP_ONLY mode, we can just delete it in LDAP. For READ_ONLY we can't delete it -> throw error + if (config.getMode() == LDAPGroupMapperMode.READ_ONLY) { + throw new ModelException("Not possible to delete LDAP group mappings as mapper mode is READ_ONLY"); + } else { + // Delete ldap role mappings + cachedLDAPGroupMappings = null; + deleteGroupMappingInLDAP(ldapUser, ldapGroup); + } + } + } + + @Override + public boolean isMemberOf(GroupModel group) { + Set ldapGroupMappings = getGroups(); + return ldapGroupMappings.contains(group); + } + + protected Set getLDAPGroupMappingsConverted() { + if (cachedLDAPGroupMappings != null) { + return new HashSet<>(cachedLDAPGroupMappings); + } + + List ldapGroups = getLDAPGroupMappings(ldapUser); + + Set result = new HashSet<>(); + for (LDAPObject ldapGroup : ldapGroups) { + GroupModel kcGroup = findKcGroupOrSyncFromLDAP(ldapGroup, this); + if (kcGroup != null) { + result.add(kcGroup); + } + } + + cachedLDAPGroupMappings = new HashSet<>(result); + + return result; + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupLDAPStorageMapperFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupLDAPStorageMapperFactory.java new file mode 100644 index 0000000000..cad730e8f8 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupLDAPStorageMapperFactory.java @@ -0,0 +1,206 @@ +/* + * 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.storage.ldap.mappers.membership.group; + +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserFederationProvider; +import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.representations.idm.UserFederationMapperSyncConfigRepresentation; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; +import org.keycloak.storage.ldap.LDAPUtils; +import org.keycloak.storage.ldap.mappers.AbstractLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.AbstractLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.membership.CommonLDAPGroupMapperConfig; +import org.keycloak.storage.ldap.mappers.membership.LDAPGroupMapperMode; +import org.keycloak.storage.ldap.mappers.membership.MembershipType; +import org.keycloak.storage.ldap.mappers.membership.UserRolesRetrieveStrategy; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * @author Marek Posolda + */ +public class GroupLDAPStorageMapperFactory extends AbstractLDAPStorageMapperFactory { + + public static final String PROVIDER_ID = "group-ldap-mapper"; + + protected static final List configProperties; + protected static final Map userGroupsStrategies = new LinkedHashMap<>(); + protected static final List MEMBERSHIP_TYPES = new LinkedList<>(); + protected static final List MODES = new LinkedList<>(); + protected static final List ROLE_RETRIEVERS; + + // TODO: Merge with RoleLDAPFederationMapperFactory as there are lot of similar properties + static { + userGroupsStrategies.put(GroupMapperConfig.LOAD_GROUPS_BY_MEMBER_ATTRIBUTE, new UserRolesRetrieveStrategy.LoadRolesByMember()); + userGroupsStrategies.put(GroupMapperConfig.GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE, new UserRolesRetrieveStrategy.GetRolesFromUserMemberOfAttribute()); + userGroupsStrategies.put(GroupMapperConfig.LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY, new UserRolesRetrieveStrategy.LoadRolesByMemberRecursively()); + for (MembershipType membershipType : MembershipType.values()) { + MEMBERSHIP_TYPES.add(membershipType.toString()); + } + for (LDAPGroupMapperMode mode : LDAPGroupMapperMode.values()) { + MODES.add(mode.toString()); + } + ROLE_RETRIEVERS = new LinkedList<>(userGroupsStrategies.keySet()); + + List config = getProps(null); + configProperties = config; + } + + private static List getProps(ComponentModel parent) { + String roleObjectClasses = LDAPConstants.GROUP_OF_NAMES; + String mode = LDAPGroupMapperMode.LDAP_ONLY.toString(); + if (parent != null) { + LDAPConfig config = new LDAPConfig(parent.getConfig()); + roleObjectClasses = config.isActiveDirectory() ? LDAPConstants.GROUP : LDAPConstants.GROUP_OF_NAMES; + mode = config.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE ? LDAPGroupMapperMode.LDAP_ONLY.toString() : LDAPGroupMapperMode.READ_ONLY.toString(); + } + return ProviderConfigurationBuilder.create() + .property().name(GroupMapperConfig.GROUPS_DN) + .label("LDAP Groups DN") + .helpText("LDAP DN where are groups of this tree saved. For example 'ou=groups,dc=example,dc=org' ") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property().name(GroupMapperConfig.GROUP_NAME_LDAP_ATTRIBUTE) + .label("Group Name LDAP Attribute") + .helpText("Name of LDAP attribute, which is used in group objects for name and RDN of group. Usually it will be 'cn' . In this case typical group/role object may have DN like 'cn=Group1,ou=groups,dc=example,dc=org' ") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(LDAPConstants.CN) + .add() + .property().name(GroupMapperConfig.GROUP_OBJECT_CLASSES) + .label("Group Object Classes") + .helpText("Object class (or classes) of the group object. It's divided by comma if more classes needed. In typical LDAP deployment it could be 'groupOfNames' . In Active Directory it's usually 'group' ") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(roleObjectClasses) + .add() + .property().name(GroupMapperConfig.PRESERVE_GROUP_INHERITANCE) + .label("Preserve Group Inheritance") + .helpText("Flag whether group inheritance from LDAP should be propagated to Keycloak. If false, then all LDAP groups will be mapped as flat top-level groups in Keycloak. Otherwise group inheritance is " + + "preserved into Keycloak, but the group sync might fail if LDAP structure contains recursions or multiple parent groups per child groups") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue("true") + .add() + .property().name(GroupMapperConfig.MEMBERSHIP_LDAP_ATTRIBUTE) + .label("Membership LDAP Attribute") + .helpText("Name of LDAP attribute on group, which is used for membership mappings. Usually it will be 'member' ") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(LDAPConstants.MEMBER) + .add() + .property().name(GroupMapperConfig.MEMBERSHIP_ATTRIBUTE_TYPE) + .label("Membership Attribute Type") + .helpText("DN means that LDAP group has it's members declared in form of their full DN. For example 'member: uid=john,ou=users,dc=example,dc=com' . " + + "UID means that LDAP group has it's members declared in form of pure user uids. For example 'memberUid: john' .") + .type(ProviderConfigProperty.LIST_TYPE) + .options(MEMBERSHIP_TYPES) + .defaultValue(MembershipType.DN.toString()) + .add() + .property().name(GroupMapperConfig.GROUPS_LDAP_FILTER) + .label("LDAP Filter") + .helpText("LDAP Filter adds additional custom filter to the whole query for retrieve LDAP groups. Leave this empty if no additional filtering is needed and you want to retrieve all groups from LDAP. Otherwise make sure that filter starts with '(' and ends with ')'") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property().name(GroupMapperConfig.MODE) + .label("Mode") + .helpText("LDAP_ONLY means that all group mappings of users are retrieved from LDAP and saved into LDAP. READ_ONLY is Read-only LDAP mode where group mappings are " + + "retrieved from both LDAP and DB and merged together. New group joins are not saved to LDAP but to DB. IMPORT is Read-only LDAP mode where group mappings are " + + "retrieved from LDAP just at the time when user is imported from LDAP and then " + + "they are saved to local keycloak DB.") + .type(ProviderConfigProperty.LIST_TYPE) + .options(MODES) + .defaultValue(mode) + .add() + .property().name(GroupMapperConfig.USER_ROLES_RETRIEVE_STRATEGY) + .label("User Groups Retrieve Strategy") + .helpText("Specify how to retrieve groups of user. LOAD_GROUPS_BY_MEMBER_ATTRIBUTE means that roles of user will be retrieved by sending LDAP query to retrieve all groups where 'member' is our user. " + + "GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE means that groups of user will be retrieved from 'memberOf' attribute of our user. " + + "LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY is applicable just in Active Directory and it means that groups of user will be retrieved recursively with usage of LDAP_MATCHING_RULE_IN_CHAIN Ldap extension.") + .type(ProviderConfigProperty.LIST_TYPE) + .options(ROLE_RETRIEVERS) + .defaultValue(GroupMapperConfig.LOAD_GROUPS_BY_MEMBER_ATTRIBUTE) + .add() + .property().name(GroupMapperConfig.MAPPED_GROUP_ATTRIBUTES) + .label("Mapped Group Attributes") + .helpText("List of names of attributes divided by comma. This points to the list of attributes on LDAP group, which will be mapped as attributes of Group in Keycloak. " + + "Leave this empty if no additional group attributes are required to be mapped in Keycloak. ") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property().name(GroupMapperConfig.DROP_NON_EXISTING_GROUPS_DURING_SYNC) + .label("Drop non-existing groups during sync") + .helpText("If this flag is true, then during sync of groups from LDAP to Keycloak, we will keep just those Keycloak groups, which still exists in LDAP. Rest will be deleted") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue("false") + .add() + .build(); + } + + @Override + public String getHelpText() { + return "Used to map group mappings of groups from some LDAP DN to Keycloak group mappings"; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public UserFederationMapperSyncConfigRepresentation getSyncConfig() { + return new UserFederationMapperSyncConfigRepresentation(true, "sync-ldap-groups-to-keycloak", true, "sync-keycloak-groups-to-ldap"); + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { + checkMandatoryConfigAttribute(GroupMapperConfig.GROUPS_DN, "LDAP Groups DN", config); + checkMandatoryConfigAttribute(GroupMapperConfig.MODE, "Mode", config); + + String mt = config.getConfig().getFirst(CommonLDAPGroupMapperConfig.MEMBERSHIP_ATTRIBUTE_TYPE); + MembershipType membershipType = mt==null ? MembershipType.DN : Enum.valueOf(MembershipType.class, mt); + boolean preserveGroupInheritance = Boolean.parseBoolean(config.getConfig().getFirst(GroupMapperConfig.PRESERVE_GROUP_INHERITANCE)); + if (preserveGroupInheritance && membershipType != MembershipType.DN) { + throw new ComponentValidationException("ldapErrorCantPreserveGroupInheritanceWithUIDMembershipType"); + } + + LDAPUtils.validateCustomLdapFilter(config.getConfig().getFirst(GroupMapperConfig.GROUPS_LDAP_FILTER)); + } + + @Override + protected AbstractLDAPStorageMapper createMapper(ComponentModel mapperModel, LDAPStorageProvider federationProvider, RealmModel realm) { + return new GroupLDAPStorageMapper(mapperModel, federationProvider, realm, this); + } + + protected UserRolesRetrieveStrategy getUserGroupsRetrieveStrategy(String strategyKey) { + return userGroupsStrategies.get(strategyKey); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupMapperConfig.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupMapperConfig.java new file mode 100644 index 0000000000..99cde4de7d --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupMapperConfig.java @@ -0,0 +1,125 @@ +/* + * 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.storage.ldap.mappers.membership.group; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.mappers.AbstractLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.membership.CommonLDAPGroupMapperConfig; + +import java.util.Collection; +import java.util.Collections; + +/** + * @author Marek Posolda + */ +public class GroupMapperConfig extends CommonLDAPGroupMapperConfig { + + // LDAP DN where are groups of this tree saved. + public static final String GROUPS_DN = "groups.dn"; + + // Name of LDAP attribute, which is used in group objects for name and RDN of group. Usually it will be "cn" + public static final String GROUP_NAME_LDAP_ATTRIBUTE = "group.name.ldap.attribute"; + + // Object classes of the group object. + public static final String GROUP_OBJECT_CLASSES = "group.object.classes"; + + // Flag whether group inheritance from LDAP should be propagated to Keycloak group inheritance. + public static final String PRESERVE_GROUP_INHERITANCE = "preserve.group.inheritance"; + + // Customized LDAP filter which is added to the whole LDAP query + public static final String GROUPS_LDAP_FILTER = "groups.ldap.filter"; + + // Name of attributes of the LDAP group object, which will be mapped as attributes of Group in Keycloak + public static final String MAPPED_GROUP_ATTRIBUTES = "mapped.group.attributes"; + + // During sync of groups from LDAP to Keycloak, we will keep just those Keycloak groups, which still exists in LDAP. Rest will be deleted + public static final String DROP_NON_EXISTING_GROUPS_DURING_SYNC = "drop.non.existing.groups.during.sync"; + + // See UserRolesRetrieveStrategy + public static final String LOAD_GROUPS_BY_MEMBER_ATTRIBUTE = "LOAD_GROUPS_BY_MEMBER_ATTRIBUTE"; + public static final String GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE = "GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE"; + public static final String LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY = "LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY"; + + public GroupMapperConfig(ComponentModel mapperModel) { + super(mapperModel); + } + + + public String getGroupsDn() { + String groupsDn = mapperModel.getConfig().getFirst(GROUPS_DN); + if (groupsDn == null) { + throw new ModelException("Groups DN is null! Check your configuration"); + } + return groupsDn; + } + + @Override + public String getLDAPGroupsDn() { + return getGroupsDn(); + } + + public String getGroupNameLdapAttribute() { + String rolesRdnAttr = mapperModel.getConfig().getFirst(GROUP_NAME_LDAP_ATTRIBUTE); + return rolesRdnAttr!=null ? rolesRdnAttr : LDAPConstants.CN; + } + + @Override + public String getLDAPGroupNameLdapAttribute() { + return getGroupNameLdapAttribute(); + } + + public boolean isPreserveGroupsInheritance() { + return AbstractLDAPStorageMapper.parseBooleanParameter(mapperModel, PRESERVE_GROUP_INHERITANCE); + } + + public String getMembershipLdapAttribute() { + String membershipAttrName = mapperModel.getConfig().getFirst(MEMBERSHIP_LDAP_ATTRIBUTE); + return membershipAttrName!=null ? membershipAttrName : LDAPConstants.MEMBER; + } + + public Collection getGroupObjectClasses(LDAPStorageProvider ldapProvider) { + String objectClasses = mapperModel.getConfig().getFirst(GROUP_OBJECT_CLASSES); + if (objectClasses == null) { + // For Active directory, the default is 'group' . For other servers 'groupOfNames' + objectClasses = ldapProvider.getLdapIdentityStore().getConfig().isActiveDirectory() ? LDAPConstants.GROUP : LDAPConstants.GROUP_OF_NAMES; + } + + return getConfigValues(objectClasses); + } + + public Collection getGroupAttributes() { + String groupAttrs = mapperModel.getConfig().getFirst(MAPPED_GROUP_ATTRIBUTES); + return (groupAttrs == null) ? Collections.emptySet() : getConfigValues(groupAttrs); + } + + public String getCustomLdapFilter() { + return mapperModel.getConfig().getFirst(GROUPS_LDAP_FILTER); + } + + public boolean isDropNonExistingGroupsDuringSync() { + return AbstractLDAPStorageMapper.parseBooleanParameter(mapperModel, DROP_NON_EXISTING_GROUPS_DURING_SYNC); + } + + public String getUserGroupsRetrieveStrategy() { + String strategyString = mapperModel.getConfig().getFirst(USER_ROLES_RETRIEVE_STRATEGY); + return strategyString!=null ? strategyString : LOAD_GROUPS_BY_MEMBER_ATTRIBUTE; + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupTreeResolver.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupTreeResolver.java new file mode 100644 index 0000000000..d38f361cc3 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/group/GroupTreeResolver.java @@ -0,0 +1,204 @@ +/* + * 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.storage.ldap.mappers.membership.group; + +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * @author Marek Posolda + */ +public class GroupTreeResolver { + + + /** + * Fully resolves list of group trees to be used in Keycloak. The input is group info (usually from LDAP) where each "Group" object contains + * just it's name and direct children. + * + * The operation also performs validation as rules for LDAP are less strict than for Keycloak (In LDAP, the recursion is possible and multiple parents of single group is also allowed) + * + * @param groups + * @return + * @throws GroupTreeResolveException + */ + public List resolveGroupTree(List groups) throws GroupTreeResolveException { + // 1- Get parents of each group + Map> parentsTree = getParentsTree(groups); + + // 2 - Get rootGroups (groups without parent) and check if there is no group with multiple parents + List rootGroups = new LinkedList<>(); + for (Map.Entry> group : parentsTree.entrySet()) { + int parentCount = group.getValue().size(); + if (parentCount == 0) { + rootGroups.add(group.getKey()); + } else if (parentCount > 1) { + throw new GroupTreeResolveException("Group '" + group.getKey() + "' detected to have multiple parents. This is not allowed in Keycloak. Parents are: " + group.getValue()); + } + } + + // 3 - Just convert to map for easier retrieval + Map asMap = new TreeMap<>(); + for (Group group : groups) { + asMap.put(group.getGroupName(), group); + } + + // 4 - Now we have rootGroups. Let's resolve them + List finalResult = new LinkedList<>(); + Set visitedGroups = new TreeSet<>(); + for (String rootGroupName : rootGroups) { + List subtree = new LinkedList<>(); + subtree.add(rootGroupName); + GroupTreeEntry groupTree = resolveGroupTree(rootGroupName, asMap, visitedGroups, subtree); + finalResult.add(groupTree); + } + + + // 5 - Check recursion + if (visitedGroups.size() != asMap.size()) { + // Recursion detected. Try to find where it is + for (Map.Entry entry : asMap.entrySet()) { + String groupName = entry.getKey(); + if (!visitedGroups.contains(groupName)) { + List subtree = new LinkedList<>(); + subtree.add(groupName); + + Set newVisitedGroups = new TreeSet<>(); + resolveGroupTree(groupName, asMap, newVisitedGroups, subtree); + visitedGroups.addAll(newVisitedGroups); + } + } + + // Shouldn't happen + throw new GroupTreeResolveException("Illegal state: Recursion detected, but wasn't able to find it"); + } + + return finalResult; + } + + private Map> getParentsTree(List groups) throws GroupTreeResolveException { + Map> result = new TreeMap<>(); + + for (Group group : groups) { + result.put(group.getGroupName(), new LinkedList()); + } + + for (Group group : groups) { + for (String child : group.getChildrenNames()) { + List list = result.get(child); + if (list == null) { + throw new GroupTreeResolveException("Group '" + child + "' referenced as member of group '" + group.getGroupName() + "' doesn't exists"); + } + list.add(group.getGroupName()); + } + } + return result; + } + + private GroupTreeEntry resolveGroupTree(String groupName, Map asMap, Set visitedGroups, List currentSubtree) throws GroupTreeResolveException { + if (visitedGroups.contains(groupName)) { + throw new GroupTreeResolveException("Recursion detected when trying to resolve group '" + groupName + "'. Whole recursion path: " + currentSubtree); + } + + visitedGroups.add(groupName); + + Group group = asMap.get(groupName); + + List children = new LinkedList<>(); + GroupTreeEntry result = new GroupTreeEntry(group.getGroupName(), children); + + for (String childrenName : group.getChildrenNames()) { + List subtreeCopy = new LinkedList<>(currentSubtree); + subtreeCopy.add(childrenName); + GroupTreeEntry childEntry = resolveGroupTree(childrenName, asMap, visitedGroups, subtreeCopy); + children.add(childEntry); + } + + return result; + } + + + + // static classes + + public static class GroupTreeResolveException extends Exception { + + public GroupTreeResolveException(String message) { + super(message); + } + } + + + public static class Group { + + private final String groupName; + private final List childrenNames; + + public Group(String groupName, String... childrenNames) { + this(groupName, Arrays.asList(childrenNames)); + } + + public Group(String groupName, Collection childrenNames) { + this.groupName = groupName; + this.childrenNames = new LinkedList<>(childrenNames); + } + + public String getGroupName() { + return groupName; + } + + public List getChildrenNames() { + return childrenNames; + } + } + + public static class GroupTreeEntry { + + private final String groupName; + private final List children; + + public GroupTreeEntry(String groupName, List children) { + this.groupName = groupName; + this.children = children; + } + + public String getGroupName() { + return groupName; + } + + public List getChildren() { + return children; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("{ " + groupName + " -> [ "); + for (GroupTreeEntry child : children) { + builder.append(child.toString()); + } + builder.append(" ]}"); + + return builder.toString(); + } + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleLDAPStorageMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleLDAPStorageMapper.java new file mode 100644 index 0000000000..db207fb999 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleLDAPStorageMapper.java @@ -0,0 +1,450 @@ +/* + * 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.storage.ldap.mappers.membership.role; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleContainerModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPUtils; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +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.mappers.AbstractLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.membership.CommonLDAPGroupMapper; +import org.keycloak.storage.ldap.mappers.membership.CommonLDAPGroupMapperConfig; +import org.keycloak.storage.ldap.mappers.membership.LDAPGroupMapperMode; +import org.keycloak.storage.ldap.mappers.membership.UserRolesRetrieveStrategy; +import org.keycloak.storage.user.SynchronizationResult; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Map realm roles or roles of particular client to LDAP groups + * + * @author Marek Posolda + */ +public class RoleLDAPStorageMapper extends AbstractLDAPStorageMapper implements CommonLDAPGroupMapper { + + private static final Logger logger = Logger.getLogger(RoleLDAPStorageMapper.class); + + private final RoleMapperConfig config; + private final RoleLDAPStorageMapperFactory factory; + + public RoleLDAPStorageMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, RealmModel realm, RoleLDAPStorageMapperFactory factory) { + super(mapperModel, ldapProvider, realm); + this.config = new RoleMapperConfig(mapperModel); + this.factory = factory; + } + + + @Override + public LDAPQuery createLDAPGroupQuery() { + return createRoleQuery(); + } + + @Override + public CommonLDAPGroupMapperConfig getConfig() { + return config; + } + + + @Override + public void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, boolean isCreate) { + LDAPGroupMapperMode mode = config.getMode(); + + // For now, import LDAP role mappings just during create + if (mode == LDAPGroupMapperMode.IMPORT && isCreate) { + + List ldapRoles = getLDAPRoleMappings(ldapUser); + + // Import role mappings from LDAP into Keycloak DB + String roleNameAttr = config.getRoleNameLdapAttribute(); + for (LDAPObject ldapRole : ldapRoles) { + String roleName = ldapRole.getAttributeAsString(roleNameAttr); + + RoleContainerModel roleContainer = getTargetRoleContainer(); + RoleModel role = roleContainer.getRole(roleName); + + if (role == null) { + role = roleContainer.addRole(roleName); + } + + logger.debugf("Granting role [%s] to user [%s] during import from LDAP", roleName, user.getUsername()); + user.grantRole(role); + } + } + } + + @Override + public void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser) { + } + + + // Sync roles from LDAP to Keycloak DB + @Override + public SynchronizationResult syncDataFromFederationProviderToKeycloak() { + SynchronizationResult syncResult = new SynchronizationResult() { + + @Override + public String getStatus() { + return String.format("%d imported roles, %d roles already exists in Keycloak", getAdded(), getUpdated()); + } + + }; + + logger.debugf("Syncing roles from LDAP into Keycloak DB. Mapper is [%s], LDAP provider is [%s]", mapperModel.getName(), ldapProvider.getModel().getName()); + + // Send LDAP query to load all roles + LDAPQuery ldapRoleQuery = createRoleQuery(); + List ldapRoles = LDAPUtils.loadAllLDAPObjects(ldapRoleQuery, ldapProvider); + + RoleContainerModel roleContainer = getTargetRoleContainer(); + String rolesRdnAttr = config.getRoleNameLdapAttribute(); + for (LDAPObject ldapRole : ldapRoles) { + String roleName = ldapRole.getAttributeAsString(rolesRdnAttr); + + if (roleContainer.getRole(roleName) == null) { + logger.debugf("Syncing role [%s] from LDAP to keycloak DB", roleName); + roleContainer.addRole(roleName); + syncResult.increaseAdded(); + } else { + syncResult.increaseUpdated(); + } + } + + return syncResult; + } + + + // Sync roles from Keycloak back to LDAP + @Override + public SynchronizationResult syncDataFromKeycloakToFederationProvider() { + SynchronizationResult syncResult = new SynchronizationResult() { + + @Override + public String getStatus() { + return String.format("%d roles imported to LDAP, %d roles already existed in LDAP", getAdded(), getUpdated()); + } + + }; + + if (config.getMode() != LDAPGroupMapperMode.LDAP_ONLY) { + logger.warnf("Ignored sync for federation mapper '%s' as it's mode is '%s'", mapperModel.getName(), config.getMode().toString()); + return syncResult; + } + + logger.debugf("Syncing roles from Keycloak into LDAP. Mapper is [%s], LDAP provider is [%s]", mapperModel.getName(), ldapProvider.getModel().getName()); + + // Send LDAP query to see which roles exists there + LDAPQuery ldapQuery = createRoleQuery(); + List ldapRoles = ldapQuery.getResultList(); + + Set ldapRoleNames = new HashSet<>(); + String rolesRdnAttr = config.getRoleNameLdapAttribute(); + for (LDAPObject ldapRole : ldapRoles) { + String roleName = ldapRole.getAttributeAsString(rolesRdnAttr); + ldapRoleNames.add(roleName); + } + + + RoleContainerModel roleContainer = getTargetRoleContainer(); + Set keycloakRoles = roleContainer.getRoles(); + + for (RoleModel keycloakRole : keycloakRoles) { + String roleName = keycloakRole.getName(); + if (ldapRoleNames.contains(roleName)) { + syncResult.increaseUpdated(); + } else { + logger.debugf("Syncing role [%s] from Keycloak to LDAP", roleName); + createLDAPRole(roleName); + syncResult.increaseAdded(); + } + } + + return syncResult; + } + + // TODO: Possible to merge with GroupMapper and move to common class + public LDAPQuery createRoleQuery() { + LDAPQuery ldapQuery = new LDAPQuery(ldapProvider); + + // For now, use same search scope, which is configured "globally" and used for user's search. + ldapQuery.setSearchScope(ldapProvider.getLdapIdentityStore().getConfig().getSearchScope()); + + String rolesDn = config.getRolesDn(); + ldapQuery.setSearchDn(rolesDn); + + Collection roleObjectClasses = config.getRoleObjectClasses(ldapProvider); + ldapQuery.addObjectClasses(roleObjectClasses); + + String rolesRdnAttr = config.getRoleNameLdapAttribute(); + + String customFilter = config.getCustomLdapFilter(); + if (customFilter != null && customFilter.trim().length() > 0) { + Condition customFilterCondition = new LDAPQueryConditionsBuilder().addCustomLDAPFilter(customFilter); + ldapQuery.addWhereCondition(customFilterCondition); + } + + String membershipAttr = config.getMembershipLdapAttribute(); + ldapQuery.addReturningLdapAttribute(rolesRdnAttr); + ldapQuery.addReturningLdapAttribute(membershipAttr); + + return ldapQuery; + } + + protected RoleContainerModel getTargetRoleContainer() { + boolean realmRolesMapping = config.isRealmRolesMapping(); + if (realmRolesMapping) { + return realm; + } else { + String clientId = config.getClientId(); + if (clientId == null) { + throw new ModelException("Using client roles mapping is requested, but parameter client.id not found!"); + } + ClientModel client = realm.getClientByClientId(clientId); + if (client == null) { + throw new ModelException("Can't found requested client with clientId: " + clientId); + } + return client; + } + } + + + public LDAPObject createLDAPRole(String roleName) { + LDAPObject ldapRole = LDAPUtils.createLDAPGroup(ldapProvider, roleName, config.getRoleNameLdapAttribute(), config.getRoleObjectClasses(ldapProvider), + config.getRolesDn(), Collections.>emptyMap()); + + logger.debugf("Creating role [%s] to LDAP with DN [%s]", roleName, ldapRole.getDn().toString()); + return ldapRole; + } + + public void addRoleMappingInLDAP(String roleName, LDAPObject ldapUser) { + LDAPObject ldapRole = loadLDAPRoleByName(roleName); + if (ldapRole == null) { + ldapRole = createLDAPRole(roleName); + } + + LDAPUtils.addMember(ldapProvider, config.getMembershipTypeLdapAttribute(), config.getMembershipLdapAttribute(), ldapRole, ldapUser, true); + } + + public void deleteRoleMappingInLDAP(LDAPObject ldapUser, LDAPObject ldapRole) { + LDAPUtils.deleteMember(ldapProvider, config.getMembershipTypeLdapAttribute(), config.getMembershipLdapAttribute(), ldapRole, ldapUser, true); + } + + public LDAPObject loadLDAPRoleByName(String roleName) { + LDAPQuery ldapQuery = createRoleQuery(); + Condition roleNameCondition = new LDAPQueryConditionsBuilder().equal(config.getRoleNameLdapAttribute(), roleName); + ldapQuery.addWhereCondition(roleNameCondition); + return ldapQuery.getFirstResult(); + } + + protected List getLDAPRoleMappings(LDAPObject ldapUser) { + String strategyKey = config.getUserRolesRetrieveStrategy(); + UserRolesRetrieveStrategy strategy = factory.getUserRolesRetrieveStrategy(strategyKey); + return strategy.getLDAPRoleMappings(this, ldapUser); + } + + @Override + public UserModel proxy(LDAPObject ldapUser, UserModel delegate) { + final LDAPGroupMapperMode mode = config.getMode(); + + // For IMPORT mode, all operations are performed against local DB + if (mode == LDAPGroupMapperMode.IMPORT) { + return delegate; + } else { + return new LDAPRoleMappingsUserDelegate(delegate, ldapUser); + } + } + + @Override + public void beforeLDAPQuery(LDAPQuery query) { + String strategyKey = config.getUserRolesRetrieveStrategy(); + UserRolesRetrieveStrategy strategy = factory.getUserRolesRetrieveStrategy(strategyKey); + strategy.beforeUserLDAPQuery(query); + } + + + + public class LDAPRoleMappingsUserDelegate extends UserModelDelegate { + + private final LDAPObject ldapUser; + private final RoleContainerModel roleContainer; + + // Avoid loading role mappings from LDAP more times per-request + private Set cachedLDAPRoleMappings; + + public LDAPRoleMappingsUserDelegate(UserModel user, LDAPObject ldapUser) { + super(user); + this.ldapUser = ldapUser; + this.roleContainer = getTargetRoleContainer(); + } + + @Override + public Set getRealmRoleMappings() { + if (roleContainer.equals(realm)) { + Set ldapRoleMappings = getLDAPRoleMappingsConverted(); + + if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) { + // Use just role mappings from LDAP + return ldapRoleMappings; + } else { + // Merge mappings from both DB and LDAP + Set modelRoleMappings = super.getRealmRoleMappings(); + ldapRoleMappings.addAll(modelRoleMappings); + return ldapRoleMappings; + } + } else { + return super.getRealmRoleMappings(); + } + } + + @Override + public Set getClientRoleMappings(ClientModel client) { + if (roleContainer.equals(client)) { + Set ldapRoleMappings = getLDAPRoleMappingsConverted(); + + if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) { + // Use just role mappings from LDAP + return ldapRoleMappings; + } else { + // Merge mappings from both DB and LDAP + Set modelRoleMappings = super.getClientRoleMappings(client); + ldapRoleMappings.addAll(modelRoleMappings); + return ldapRoleMappings; + } + } else { + return super.getClientRoleMappings(client); + } + } + + @Override + public boolean hasRole(RoleModel role) { + Set roles = getRoleMappings(); + return KeycloakModelUtils.hasRole(roles, role) + || KeycloakModelUtils.hasRoleFromGroup(getGroups(), role, true); + } + + @Override + public void grantRole(RoleModel role) { + if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) { + + if (role.getContainer().equals(roleContainer)) { + + // We need to create new role mappings in LDAP + cachedLDAPRoleMappings = null; + addRoleMappingInLDAP(role.getName(), ldapUser); + } else { + super.grantRole(role); + } + } else { + super.grantRole(role); + } + } + + @Override + public Set getRoleMappings() { + Set modelRoleMappings = super.getRoleMappings(); + + Set ldapRoleMappings = getLDAPRoleMappingsConverted(); + + if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) { + // For LDAP-only we want to retrieve role mappings of target container just from LDAP + Set modelRolesCopy = new HashSet<>(modelRoleMappings); + for (RoleModel role : modelRolesCopy) { + if (role.getContainer().equals(roleContainer)) { + modelRoleMappings.remove(role); + } + } + } + + modelRoleMappings.addAll(ldapRoleMappings); + return modelRoleMappings; + } + + protected Set getLDAPRoleMappingsConverted() { + if (cachedLDAPRoleMappings != null) { + return new HashSet<>(cachedLDAPRoleMappings); + } + + List ldapRoles = getLDAPRoleMappings(ldapUser); + + Set roles = new HashSet<>(); + String roleNameLdapAttr = config.getRoleNameLdapAttribute(); + for (LDAPObject role : ldapRoles) { + String roleName = role.getAttributeAsString(roleNameLdapAttr); + RoleModel modelRole = roleContainer.getRole(roleName); + if (modelRole == null) { + // Add role to local DB + modelRole = roleContainer.addRole(roleName); + } + roles.add(modelRole); + } + + cachedLDAPRoleMappings = new HashSet<>(roles); + + return roles; + } + + @Override + public void deleteRoleMapping(RoleModel role) { + if (role.getContainer().equals(roleContainer)) { + + LDAPQuery ldapQuery = createRoleQuery(); + LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder(); + Condition roleNameCondition = conditionsBuilder.equal(config.getRoleNameLdapAttribute(), role.getName()); + String membershipUserAttr = LDAPUtils.getMemberValueOfChildObject(ldapUser, config.getMembershipTypeLdapAttribute()); + Condition membershipCondition = conditionsBuilder.equal(config.getMembershipLdapAttribute(), membershipUserAttr); + ldapQuery.addWhereCondition(roleNameCondition).addWhereCondition(membershipCondition); + LDAPObject ldapRole = ldapQuery.getFirstResult(); + + if (ldapRole == null) { + // Role mapping doesn't exist in LDAP. For LDAP_ONLY mode, we don't need to do anything. For READ_ONLY, delete it in local DB. + if (config.getMode() == LDAPGroupMapperMode.READ_ONLY) { + super.deleteRoleMapping(role); + } + } else { + // Role mappings exists in LDAP. For LDAP_ONLY mode, we can just delete it in LDAP. For READ_ONLY we can't delete it -> throw error + if (config.getMode() == LDAPGroupMapperMode.READ_ONLY) { + throw new ModelException("Not possible to delete LDAP role mappings as mapper mode is READ_ONLY"); + } else { + // Delete ldap role mappings + cachedLDAPRoleMappings = null; + deleteRoleMappingInLDAP(ldapUser, ldapRole); + } + } + } else { + super.deleteRoleMapping(role); + } + } + } + + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleLDAPStorageMapperFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleLDAPStorageMapperFactory.java new file mode 100644 index 0000000000..7d9a52ff18 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleLDAPStorageMapperFactory.java @@ -0,0 +1,200 @@ +/* + * 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.storage.ldap.mappers.membership.role; + +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserFederationProvider; +import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.representations.idm.UserFederationMapperSyncConfigRepresentation; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; +import org.keycloak.storage.ldap.LDAPUtils; +import org.keycloak.storage.ldap.mappers.AbstractLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.AbstractLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.membership.LDAPGroupMapperMode; +import org.keycloak.storage.ldap.mappers.membership.MembershipType; +import org.keycloak.storage.ldap.mappers.membership.UserRolesRetrieveStrategy; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * @author Marek Posolda + */ +public class RoleLDAPStorageMapperFactory extends AbstractLDAPStorageMapperFactory { + + public static final String PROVIDER_ID = "role-ldap-mapper"; + + protected static final List configProperties; + protected static final Map userRolesStrategies = new LinkedHashMap<>(); + protected static final List MEMBERSHIP_TYPES = new LinkedList<>(); + protected static final List MODES = new LinkedList<>(); + protected static final List roleRetrievers; + + static { + userRolesStrategies.put(RoleMapperConfig.LOAD_ROLES_BY_MEMBER_ATTRIBUTE, new UserRolesRetrieveStrategy.LoadRolesByMember()); + userRolesStrategies.put(RoleMapperConfig.GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE, new UserRolesRetrieveStrategy.GetRolesFromUserMemberOfAttribute()); + userRolesStrategies.put(RoleMapperConfig.LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY, new UserRolesRetrieveStrategy.LoadRolesByMemberRecursively()); + + for (MembershipType membershipType : MembershipType.values()) { + MEMBERSHIP_TYPES.add(membershipType.toString()); + } + + for (LDAPGroupMapperMode mode : LDAPGroupMapperMode.values()) { + MODES.add(mode.toString()); + } + roleRetrievers = new LinkedList<>(userRolesStrategies.keySet()); + + List config = getProps(null); + configProperties = config; + } + + private static List getProps(ComponentModel parent) { + String roleObjectClasses = LDAPConstants.GROUP_OF_NAMES; + String mode = LDAPGroupMapperMode.LDAP_ONLY.toString(); + if (parent != null) { + LDAPConfig config = new LDAPConfig(parent.getConfig()); + roleObjectClasses = config.isActiveDirectory() ? LDAPConstants.GROUP : LDAPConstants.GROUP_OF_NAMES; + mode = config.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE ? LDAPGroupMapperMode.LDAP_ONLY.toString() : LDAPGroupMapperMode.READ_ONLY.toString(); + } + return ProviderConfigurationBuilder.create() + .property().name(RoleMapperConfig.ROLES_DN) + .label("LDAP Roles DN") + .helpText("LDAP DN where are roles of this tree saved. For example 'ou=finance,dc=example,dc=org' ") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property().name(RoleMapperConfig.ROLE_NAME_LDAP_ATTRIBUTE) + .label("Role Name LDAP Attribute") + .helpText("Name of LDAP attribute, which is used in role objects for name and RDN of role. Usually it will be 'cn' . In this case typical group/role object may have DN like 'cn=role1,ou=finance,dc=example,dc=org' ") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(LDAPConstants.CN) + .add() + .property().name(RoleMapperConfig.ROLE_OBJECT_CLASSES) + .label("Role Object Classes") + .helpText("Object class (or classes) of the role object. It's divided by comma if more classes needed. In typical LDAP deployment it could be 'groupOfNames' . In Active Directory it's usually 'group' ") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(roleObjectClasses) + .add() + .property().name(RoleMapperConfig.MEMBERSHIP_LDAP_ATTRIBUTE) + .label("Membership LDAP Attribute") + .helpText("Name of LDAP attribute on role, which is used for membership mappings. Usually it will be 'member' ") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue(LDAPConstants.MEMBER) + .add() + .property().name(RoleMapperConfig.MEMBERSHIP_ATTRIBUTE_TYPE) + .label("Membership Attribute Type") + .helpText("DN means that LDAP role has it's members declared in form of their full DN. For example 'member: uid=john,ou=users,dc=example,dc=com' . " + + "UID means that LDAP role has it's members declared in form of pure user uids. For example 'memberUid: john' .") + .type(ProviderConfigProperty.LIST_TYPE) + .options(MEMBERSHIP_TYPES) + .defaultValue(MembershipType.DN.toString()) + .add() + .property().name(RoleMapperConfig.ROLES_LDAP_FILTER) + .label("LDAP Filter") + .helpText("LDAP Filter adds additional custom filter to the whole query for retrieve LDAP roles. Leave this empty if no additional filtering is needed and you want to retrieve all roles from LDAP. Otherwise make sure that filter starts with '(' and ends with ')'") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property().name(RoleMapperConfig.MODE) + .label("Mode") + .helpText("LDAP_ONLY means that all role mappings are retrieved from LDAP and saved into LDAP. READ_ONLY is Read-only LDAP mode where role mappings are " + + "retrieved from both LDAP and DB and merged together. New role grants are not saved to LDAP but to DB. IMPORT is Read-only LDAP mode where role mappings are retrieved from LDAP just at the time when user is imported from LDAP and then " + + "they are saved to local keycloak DB.") + .type(ProviderConfigProperty.LIST_TYPE) + .options(MODES) + .defaultValue(mode) + .add() + .property().name(RoleMapperConfig.USER_ROLES_RETRIEVE_STRATEGY) + .label("User Roles Retrieve Strategy") + .helpText("Specify how to retrieve roles of user. LOAD_ROLES_BY_MEMBER_ATTRIBUTE means that roles of user will be retrieved by sending LDAP query to retrieve all roles where 'member' is our user. " + + "GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE means that roles of user will be retrieved from 'memberOf' attribute of our user. " + + "LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY is applicable just in Active Directory and it means that roles of user will be retrieved recursively with usage of LDAP_MATCHING_RULE_IN_CHAIN Ldap extension.") + .type(ProviderConfigProperty.LIST_TYPE) + .options(roleRetrievers) + .defaultValue(RoleMapperConfig.LOAD_ROLES_BY_MEMBER_ATTRIBUTE) + .add() + .property().name(RoleMapperConfig.USE_REALM_ROLES_MAPPING) + .label("Use Realm Roles Mapping") + .helpText("If true, then LDAP role mappings will be mapped to realm role mappings in Keycloak. Otherwise it will be mapped to client role mappings") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue("true") + .add() + .property().name(RoleMapperConfig.CLIENT_ID) + .label("Client ID") + .helpText("Client ID of client to which LDAP role mappings will be mapped. Applicable just if 'Use Realm Roles Mapping' is false") + .type(ProviderConfigProperty.CLIENT_LIST_TYPE) + .add() + .build(); + } + + + @Override + public String getHelpText() { + return "Used to map role mappings of roles from some LDAP DN to Keycloak role mappings of either realm roles or client roles of particular client"; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public UserFederationMapperSyncConfigRepresentation getSyncConfig() { + return new UserFederationMapperSyncConfigRepresentation(true, "sync-ldap-roles-to-keycloak", true, "sync-keycloak-roles-to-ldap"); + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { + checkMandatoryConfigAttribute(RoleMapperConfig.ROLES_DN, "LDAP Roles DN", config); + checkMandatoryConfigAttribute(RoleMapperConfig.MODE, "Mode", config); + + String realmMappings = config.getConfig().getFirst(RoleMapperConfig.USE_REALM_ROLES_MAPPING); + boolean useRealmMappings = Boolean.parseBoolean(realmMappings); + if (!useRealmMappings) { + String clientId = config.getConfig().getFirst(RoleMapperConfig.CLIENT_ID); + if (clientId == null || clientId.trim().isEmpty()) { + throw new ComponentValidationException("ldapErrorMissingClientId"); + } + } + + LDAPUtils.validateCustomLdapFilter(config.getConfig().getFirst(RoleMapperConfig.ROLES_LDAP_FILTER)); + } + + @Override + protected AbstractLDAPStorageMapper createMapper(ComponentModel mapperModel, LDAPStorageProvider federationProvider, RealmModel realm) { + return new RoleLDAPStorageMapper(mapperModel, federationProvider, realm, this); + } + + protected UserRolesRetrieveStrategy getUserRolesRetrieveStrategy(String strategyKey) { + return userRolesStrategies.get(strategyKey); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleMapperConfig.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleMapperConfig.java new file mode 100755 index 0000000000..0dbb87c4d8 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/membership/role/RoleMapperConfig.java @@ -0,0 +1,113 @@ +/* + * 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.storage.ldap.mappers.membership.role; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.mappers.membership.CommonLDAPGroupMapperConfig; + +import java.util.Collection; + +/** + * @author Marek Posolda + */ +public class RoleMapperConfig extends CommonLDAPGroupMapperConfig { + + // LDAP DN where are roles of this tree saved. + public static final String ROLES_DN = "roles.dn"; + + // Name of LDAP attribute, which is used in role objects for name and RDN of role. Usually it will be "cn" + public static final String ROLE_NAME_LDAP_ATTRIBUTE = "role.name.ldap.attribute"; + + // Object classes of the role object. + public static final String ROLE_OBJECT_CLASSES = "role.object.classes"; + + // Boolean option. If true, we will map LDAP roles to realm roles. If false, we will map to client roles (client specified by option CLIENT_ID) + public static final String USE_REALM_ROLES_MAPPING = "use.realm.roles.mapping"; + + // ClientId, which we want to map roles. Applicable just if "USE_REALM_ROLES_MAPPING" is false + public static final String CLIENT_ID = "client.id"; + + // Customized LDAP filter which is added to the whole LDAP query + public static final String ROLES_LDAP_FILTER = "roles.ldap.filter"; + + // See UserRolesRetrieveStrategy + public static final String LOAD_ROLES_BY_MEMBER_ATTRIBUTE = "LOAD_ROLES_BY_MEMBER_ATTRIBUTE"; + public static final String GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE = "GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE"; + public static final String LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY = "LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY"; + + + public RoleMapperConfig(ComponentModel mapperModel) { + super(mapperModel); + } + + public String getRolesDn() { + String rolesDn = mapperModel.getConfig().getFirst(ROLES_DN); + if (rolesDn == null) { + throw new ModelException("Roles DN is null! Check your configuration"); + } + return rolesDn; + } + + @Override + public String getLDAPGroupsDn() { + return getRolesDn(); + } + + public String getRoleNameLdapAttribute() { + String rolesRdnAttr = mapperModel.getConfig().getFirst(ROLE_NAME_LDAP_ATTRIBUTE); + return rolesRdnAttr!=null ? rolesRdnAttr : LDAPConstants.CN; + } + + @Override + public String getLDAPGroupNameLdapAttribute() { + return getRoleNameLdapAttribute(); + } + + public Collection getRoleObjectClasses(LDAPStorageProvider ldapProvider) { + String objectClasses = mapperModel.getConfig().getFirst(ROLE_OBJECT_CLASSES); + if (objectClasses == null) { + // For Active directory, the default is 'group' . For other servers 'groupOfNames' + objectClasses = ldapProvider.getLdapIdentityStore().getConfig().isActiveDirectory() ? LDAPConstants.GROUP : LDAPConstants.GROUP_OF_NAMES; + } + + return getConfigValues(objectClasses); + } + + public String getCustomLdapFilter() { + return mapperModel.getConfig().getFirst(ROLES_LDAP_FILTER); + } + + public boolean isRealmRolesMapping() { + String realmRolesMapping = mapperModel.getConfig().getFirst(USE_REALM_ROLES_MAPPING); + return realmRolesMapping==null || Boolean.parseBoolean(realmRolesMapping); + } + + public String getClientId() { + return mapperModel.getConfig().getFirst(CLIENT_ID); + } + + + public String getUserRolesRetrieveStrategy() { + String strategyString = mapperModel.getConfig().getFirst(USER_ROLES_RETRIEVE_STRATEGY); + return strategyString!=null ? strategyString : LOAD_ROLES_BY_MEMBER_ATTRIBUTE; + } + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java new file mode 100644 index 0000000000..7ad522801e --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java @@ -0,0 +1,289 @@ +/* + * 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.storage.ldap.mappers.msad; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.credential.CredentialInput; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; +import org.keycloak.storage.ldap.mappers.AbstractLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.PasswordUpdated; + +import javax.naming.AuthenticationException; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Mapper specific to MSAD. It's able to read the userAccountControl and pwdLastSet attributes and set actions in Keycloak based on that. + * It's also able to handle exception code from LDAP user authentication (See http://www-01.ibm.com/support/docview.wss?uid=swg21290631 ) + * + * @author Marek Posolda + */ +public class MSADUserAccountControlStorageMapper extends AbstractLDAPStorageMapper implements PasswordUpdated { + + private static final Logger logger = Logger.getLogger(MSADUserAccountControlStorageMapper.class); + + private static final Pattern AUTH_EXCEPTION_REGEX = Pattern.compile(".*AcceptSecurityContext error, data ([0-9a-f]*), v.*"); + private static final Pattern AUTH_INVALID_NEW_PASSWORD = Pattern.compile(".*error code ([0-9a-f]+) .*WILL_NOT_PERFORM.*"); + + public MSADUserAccountControlStorageMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider, RealmModel realm) { + super(mapperModel, ldapProvider, realm); + ldapProvider.setUpdater(this); + } + + @Override + public void beforeLDAPQuery(LDAPQuery query) { + query.addReturningLdapAttribute(LDAPConstants.PWD_LAST_SET); + query.addReturningLdapAttribute(LDAPConstants.USER_ACCOUNT_CONTROL); + + // This needs to be read-only and can be set to writable just on demand + query.addReturningReadOnlyLdapAttribute(LDAPConstants.PWD_LAST_SET); + + if (ldapProvider.getEditMode() != LDAPStorageProviderFactory.EditMode.WRITABLE) { + query.addReturningReadOnlyLdapAttribute(LDAPConstants.USER_ACCOUNT_CONTROL); + } + } + + @Override + public void passwordUpdated(UserModel user, LDAPObject ldapUser, CredentialInput input) { + logger.debugf("Going to update userAccountControl for ldap user '%s' after successful password update", ldapUser.getDn().toString()); + + // Normally it's read-only + ldapUser.removeReadOnlyAttributeName(LDAPConstants.PWD_LAST_SET); + + ldapUser.setSingleAttribute(LDAPConstants.PWD_LAST_SET, "-1"); + + UserAccountControl control = getUserAccountControl(ldapUser); + control.remove(UserAccountControl.PASSWD_NOTREQD); + control.remove(UserAccountControl.PASSWORD_EXPIRED); + + if (user.isEnabled()) { + control.remove(UserAccountControl.ACCOUNTDISABLE); + } + + updateUserAccountControl(ldapUser, control); + } + + @Override + public UserModel proxy(LDAPObject ldapUser, UserModel delegate) { + return new MSADUserModelDelegate(delegate, ldapUser); + } + + @Override + public void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser) { + + } + + @Override + public void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, boolean isCreate) { + + } + + @Override + public boolean onAuthenticationFailure(LDAPObject ldapUser, UserModel user, AuthenticationException ldapException) { + String exceptionMessage = ldapException.getMessage(); + Matcher m = AUTH_EXCEPTION_REGEX.matcher(exceptionMessage); + if (m.matches()) { + String errorCode = m.group(1); + return processAuthErrorCode(errorCode, user); + } else { + return false; + } + } + + protected boolean processAuthErrorCode(String errorCode, UserModel user) { + logger.debugf("MSAD Error code is '%s' after failed LDAP login of user '%s'", errorCode, user.getUsername()); + + if (ldapProvider.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE) { + if (errorCode.equals("532") || errorCode.equals("773")) { + // User needs to change his MSAD password. Allow him to login, but add UPDATE_PASSWORD required action + user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD); + return true; + } else if (errorCode.equals("533")) { + // User is disabled in MSAD. Set him to disabled in KC as well + user.setEnabled(false); + return true; + } else if (errorCode.equals("775")) { + logger.warnf("Locked user '%s' attempt to login", user.getUsername()); + } + } + + return false; + } + + + protected ModelException processFailedPasswordUpdateException(ModelException e) { + if (e.getCause() == null || e.getCause().getMessage() == null) { + return e; + } + + String exceptionMessage = e.getCause().getMessage().replace('\n', ' '); + Matcher m = AUTH_INVALID_NEW_PASSWORD.matcher(exceptionMessage); + if (m.matches()) { + String errorCode = m.group(1); + if (errorCode.equals("53")) { + ModelException me = new ModelException("invalidPasswordRegexPatternMessage", e); + me.setParameters(new Object[]{"passwordConstraintViolation"}); + return me; + } + } + + return e; + } + + protected UserAccountControl getUserAccountControl(LDAPObject ldapUser) { + String userAccountControl = ldapUser.getAttributeAsString(LDAPConstants.USER_ACCOUNT_CONTROL); + long longValue = userAccountControl == null ? 0 : Long.parseLong(userAccountControl); + return new UserAccountControl(longValue); + } + + // Update user in LDAP + protected void updateUserAccountControl(LDAPObject ldapUser, UserAccountControl accountControl) { + String userAccountControlValue = String.valueOf(accountControl.getValue()); + logger.debugf("Updating userAccountControl of user '%s' to value '%s'", ldapUser.getDn().toString(), userAccountControlValue); + + ldapUser.setSingleAttribute(LDAPConstants.USER_ACCOUNT_CONTROL, userAccountControlValue); + ldapProvider.getLdapIdentityStore().update(ldapUser); + } + + + + public class MSADUserModelDelegate extends UserModelDelegate { + + private final LDAPObject ldapUser; + + public MSADUserModelDelegate(UserModel delegate, LDAPObject ldapUser) { + super(delegate); + this.ldapUser = ldapUser; + } + + @Override + public boolean isEnabled() { + boolean kcEnabled = super.isEnabled(); + + if (getPwdLastSet() > 0) { + // Merge KC and MSAD + return kcEnabled && !getUserAccountControl(ldapUser).has(UserAccountControl.ACCOUNTDISABLE); + } else { + // If new MSAD user is created and pwdLastSet is still 0, MSAD account is in disabled state. So read just from Keycloak DB. User is not able to login via MSAD anyway + return kcEnabled; + } + } + + @Override + public void setEnabled(boolean enabled) { + // Always update DB + super.setEnabled(enabled); + + if (ldapProvider.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE && getPwdLastSet() > 0) { + logger.debugf("Going to propagate enabled=%s for ldapUser '%s' to MSAD", enabled, ldapUser.getDn().toString()); + + UserAccountControl control = getUserAccountControl(ldapUser); + if (enabled) { + control.remove(UserAccountControl.ACCOUNTDISABLE); + } else { + control.add(UserAccountControl.ACCOUNTDISABLE); + } + + updateUserAccountControl(ldapUser, control); + } + } + + @Override + public void addRequiredAction(RequiredAction action) { + String actionName = action.name(); + addRequiredAction(actionName); + } + + @Override + public void addRequiredAction(String action) { + // Always update DB + super.addRequiredAction(action); + + if (ldapProvider.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE && RequiredAction.UPDATE_PASSWORD.toString().equals(action)) { + logger.debugf("Going to propagate required action UPDATE_PASSWORD to MSAD for ldap user '%s' ", ldapUser.getDn().toString()); + + // Normally it's read-only + ldapUser.removeReadOnlyAttributeName(LDAPConstants.PWD_LAST_SET); + + ldapUser.setSingleAttribute(LDAPConstants.PWD_LAST_SET, "0"); + ldapProvider.getLdapIdentityStore().update(ldapUser); + } + } + + @Override + public void removeRequiredAction(RequiredAction action) { + String actionName = action.name(); + removeRequiredAction(actionName); + } + + @Override + public void removeRequiredAction(String action) { + // Always update DB + super.removeRequiredAction(action); + + if (ldapProvider.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE && RequiredAction.UPDATE_PASSWORD.toString().equals(action)) { + + // Don't set pwdLastSet in MSAD when it is new user + UserAccountControl accountControl = getUserAccountControl(ldapUser); + if (accountControl.getValue() != 0 && !accountControl.has(UserAccountControl.PASSWD_NOTREQD)) { + logger.debugf("Going to remove required action UPDATE_PASSWORD from MSAD for ldap user '%s' ", ldapUser.getDn().toString()); + + // Normally it's read-only + ldapUser.removeReadOnlyAttributeName(LDAPConstants.PWD_LAST_SET); + + ldapUser.setSingleAttribute(LDAPConstants.PWD_LAST_SET, "-1"); + ldapProvider.getLdapIdentityStore().update(ldapUser); + } + } + } + + @Override + public Set getRequiredActions() { + Set requiredActions = super.getRequiredActions(); + + if (ldapProvider.getEditMode() == LDAPStorageProviderFactory.EditMode.WRITABLE) { + if (getPwdLastSet() == 0 || getUserAccountControl(ldapUser).has(UserAccountControl.PASSWORD_EXPIRED)) { + requiredActions = new HashSet<>(requiredActions); + requiredActions.add(RequiredAction.UPDATE_PASSWORD.toString()); + return requiredActions; + } + } + + return requiredActions; + } + + protected long getPwdLastSet() { + String pwdLastSet = ldapUser.getAttributeAsString(LDAPConstants.PWD_LAST_SET); + return pwdLastSet == null ? 0 : Long.parseLong(pwdLastSet); + } + + + } + +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapperFactory.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapperFactory.java new file mode 100644 index 0000000000..560eff0b87 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapperFactory.java @@ -0,0 +1,62 @@ +/* + * 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.storage.ldap.mappers.msad; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.mappers.AbstractLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.AbstractLDAPStorageMapperFactory; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Marek Posolda + */ +public class MSADUserAccountControlStorageMapperFactory extends AbstractLDAPStorageMapperFactory { + + public static final String PROVIDER_ID = LDAPConstants.MSAD_USER_ACCOUNT_CONTROL_MAPPER; + protected static final List configProperties = new ArrayList(); + + static { + } + + @Override + public String getHelpText() { + return "Mapper specific to MSAD. It's able to integrate the MSAD user account state into Keycloak account state (account enabled, password is expired etc). It's using userAccountControl and pwdLastSet MSAD attributes for that. " + + "For example if pwdLastSet is 0, the Keycloak user is required to update password, if userAccountControl is 514 (disabled account) the Keycloak user is disabled as well etc. Mapper is also able to handle exception code from LDAP user authentication."; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + protected AbstractLDAPStorageMapper createMapper(ComponentModel mapperModel, LDAPStorageProvider federationProvider, RealmModel realm) { + return new MSADUserAccountControlStorageMapper(mapperModel, federationProvider, realm); + } +} diff --git a/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/UserAccountControl.java b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/UserAccountControl.java new file mode 100644 index 0000000000..6d24f572e5 --- /dev/null +++ b/federation/ldap2/src/main/java/org/keycloak/storage/ldap/mappers/msad/UserAccountControl.java @@ -0,0 +1,75 @@ +/* + * 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.storage.ldap.mappers.msad; + +/** + * See https://support.microsoft.com/en-us/kb/305144 + * + * @author Marek Posolda + */ +public class UserAccountControl { + + public static final long SCRIPT = 0x0001L; + public static final long ACCOUNTDISABLE = 0x0002L; + public static final long HOMEDIR_REQUIRED = 0x0008L; + public static final long LOCKOUT = 0x0010L; + public static final long PASSWD_NOTREQD = 0x0020L; + public static final long PASSWD_CANT_CHANGE = 0x0040L; + public static final long ENCRYPTED_TEXT_PWD_ALLOWED = 0x0080L; + public static final long TEMP_DUPLICATE_ACCOUNT = 0x0100L; + public static final long NORMAL_ACCOUNT = 0x0200L; + public static final long INTERDOMAIN_TRUST_ACCOUNT = 0x0800L; + public static final long WORKSTATION_TRUST_ACCOUNT = 0x1000L; + public static final long SERVER_TRUST_ACCOUNT = 0x2000L; + public static final long DONT_EXPIRE_PASSWORD = 0x10000L; + public static final long MNS_LOGON_ACCOUNT = 0x20000L; + public static final long SMARTCARD_REQUIRED = 0x40000L; + public static final long TRUSTED_FOR_DELEGATION = 0x80000L; + public static final long NOT_DELEGATED = 0x100000L; + public static final long USE_DES_KEY_ONLY = 0x200000L; + public static final long DONT_REQ_PREAUTH = 0x400000L; + public static final long PASSWORD_EXPIRED = 0x800000L; + public static final long TRUSTED_TO_AUTH_FOR_DELEGATION = 0x1000000L; + public static final long PARTIAL_SECRETS_ACCOUNT = 0x04000000L; + + private long value; + + public UserAccountControl(long value) { + this.value = value; + } + + public boolean has(long feature) { + return (this.value & feature) > 0; + } + + public void add(long feature) { + if (!has(feature)) { + this.value += feature; + } + } + + public void remove(long feature) { + if (has(feature)) { + this.value -= feature; + } + } + + public long getValue() { + return value; + } +} diff --git a/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.provider.Spi new file mode 100755 index 0000000000..57843f04b5 --- /dev/null +++ b/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -0,0 +1,18 @@ +# +# 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. +# + +org.keycloak.storage.ldap.mappers.LDAPStorageMapperSpi \ No newline at end of file diff --git a/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory new file mode 100644 index 0000000000..0d8483fb80 --- /dev/null +++ b/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -0,0 +1 @@ +org.keycloak.storage.ldap.LDAPStorageProviderFactory \ No newline at end of file diff --git a/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.storage.ldap.mappers.LDAPStorageMapperFactory b/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.storage.ldap.mappers.LDAPStorageMapperFactory new file mode 100644 index 0000000000..ff1f0f6c9e --- /dev/null +++ b/federation/ldap2/src/main/resources/META-INF/services/org.keycloak.storage.ldap.mappers.LDAPStorageMapperFactory @@ -0,0 +1,6 @@ +org.keycloak.storage.ldap.mappers.FullNameLDAPStorageMapperFactory +org.keycloak.storage.ldap.mappers.HardcodedLDAPRoleStorageMapperFactory +org.keycloak.storage.ldap.mappers.membership.group.GroupLDAPStorageMapperFactory +org.keycloak.storage.ldap.mappers.membership.role.RoleLDAPStorageMapperFactory +org.keycloak.storage.ldap.mappers.msad.MSADUserAccountControlStorageMapperFactory +org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapperFactory diff --git a/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/GroupTreeResolverTest.java b/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/GroupTreeResolverTest.java new file mode 100644 index 0000000000..78974a2fc1 --- /dev/null +++ b/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/GroupTreeResolverTest.java @@ -0,0 +1,125 @@ +/* + * 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.storage.ldap.idm.model; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.storage.ldap.mappers.membership.group.GroupTreeResolver; + +import java.util.Arrays; +import java.util.List; + +/** + * @author Marek Posolda + */ +public class GroupTreeResolverTest { + + @Test + public void testGroupResolvingCorrect() throws GroupTreeResolver.GroupTreeResolveException { + GroupTreeResolver.Group group1 = new GroupTreeResolver.Group("group1", "group2", "group3"); + GroupTreeResolver.Group group2 = new GroupTreeResolver.Group("group2", "group4", "group5"); + GroupTreeResolver.Group group3 = new GroupTreeResolver.Group("group3", "group6"); + GroupTreeResolver.Group group4 = new GroupTreeResolver.Group("group4"); + GroupTreeResolver.Group group5 = new GroupTreeResolver.Group("group5"); + GroupTreeResolver.Group group6 = new GroupTreeResolver.Group("group6", "group7"); + GroupTreeResolver.Group group7 = new GroupTreeResolver.Group("group7"); + List groups = Arrays.asList(group1, group2, group3, group4, group5, group6, group7); + + GroupTreeResolver resolver = new GroupTreeResolver(); + List groupTree = resolver.resolveGroupTree(groups); + Assert.assertEquals(1, groupTree.size()); + Assert.assertEquals("{ group1 -> [ { group2 -> [ { group4 -> [ ]}{ group5 -> [ ]} ]}{ group3 -> [ { group6 -> [ { group7 -> [ ]} ]} ]} ]}", groupTree.get(0).toString()); + } + + @Test + public void testGroupResolvingCorrect2_multipleRootGroups() throws GroupTreeResolver.GroupTreeResolveException { + GroupTreeResolver.Group group1 = new GroupTreeResolver.Group("group1", "group8"); + GroupTreeResolver.Group group2 = new GroupTreeResolver.Group("group2"); + GroupTreeResolver.Group group3 = new GroupTreeResolver.Group("group3", "group2"); + GroupTreeResolver.Group group4 = new GroupTreeResolver.Group("group4", "group1", "group5"); + GroupTreeResolver.Group group5 = new GroupTreeResolver.Group("group5", "group6", "group7"); + GroupTreeResolver.Group group6 = new GroupTreeResolver.Group("group6"); + GroupTreeResolver.Group group7 = new GroupTreeResolver.Group("group7"); + GroupTreeResolver.Group group8 = new GroupTreeResolver.Group("group8", "group9"); + GroupTreeResolver.Group group9 = new GroupTreeResolver.Group("group9"); + List groups = Arrays.asList(group1, group2, group3, group4, group5, group6, group7, group8, group9); + + GroupTreeResolver resolver = new GroupTreeResolver(); + List groupTree = resolver.resolveGroupTree(groups); + + Assert.assertEquals(2, groupTree.size()); + Assert.assertEquals("{ group3 -> [ { group2 -> [ ]} ]}", groupTree.get(0).toString()); + Assert.assertEquals("{ group4 -> [ { group1 -> [ { group8 -> [ { group9 -> [ ]} ]} ]}{ group5 -> [ { group6 -> [ ]}{ group7 -> [ ]} ]} ]}", groupTree.get(1).toString()); + } + + + @Test + public void testGroupResolvingRecursion() { + GroupTreeResolver.Group group1 = new GroupTreeResolver.Group("group1", "group2", "group3"); + GroupTreeResolver.Group group2 = new GroupTreeResolver.Group("group2"); + GroupTreeResolver.Group group3 = new GroupTreeResolver.Group("group3", "group4"); + GroupTreeResolver.Group group4 = new GroupTreeResolver.Group("group4", "group5"); + GroupTreeResolver.Group group5 = new GroupTreeResolver.Group("group5", "group1"); + GroupTreeResolver.Group group6 = new GroupTreeResolver.Group("group6", "group7"); + GroupTreeResolver.Group group7 = new GroupTreeResolver.Group("group7"); + List groups = Arrays.asList(group1, group2, group3, group4, group5, group6, group7); + + GroupTreeResolver resolver = new GroupTreeResolver(); + try { + resolver.resolveGroupTree(groups); + Assert.fail("Exception expected because of recursion"); + } catch (GroupTreeResolver.GroupTreeResolveException gre) { + Assert.assertTrue(gre.getMessage().startsWith("Recursion detected")); + } + } + + @Test + public void testGroupResolvingMultipleParents() { + GroupTreeResolver.Group group1 = new GroupTreeResolver.Group("group1", "group2"); + GroupTreeResolver.Group group2 = new GroupTreeResolver.Group("group2"); + GroupTreeResolver.Group group3 = new GroupTreeResolver.Group("group3", "group2"); + GroupTreeResolver.Group group4 = new GroupTreeResolver.Group("group4", "group1", "group5"); + GroupTreeResolver.Group group5 = new GroupTreeResolver.Group("group5", "group4"); + List groups = Arrays.asList(group1, group2, group3, group4, group5); + + GroupTreeResolver resolver = new GroupTreeResolver(); + try { + resolver.resolveGroupTree(groups); + Assert.fail("Exception expected because of some groups have multiple parents"); + } catch (GroupTreeResolver.GroupTreeResolveException gre) { + Assert.assertTrue(gre.getMessage().contains("detected to have multiple parents")); + } + } + + + @Test + public void testGroupResolvingMissingGroup() { + GroupTreeResolver.Group group1 = new GroupTreeResolver.Group("group1", "group2"); + GroupTreeResolver.Group group2 = new GroupTreeResolver.Group("group2", "group3"); + GroupTreeResolver.Group group4 = new GroupTreeResolver.Group("group4"); + List groups = Arrays.asList(group1, group2, group4); + + GroupTreeResolver resolver = new GroupTreeResolver(); + try { + resolver.resolveGroupTree(groups); + Assert.fail("Exception expected because of missing referenced group"); + } catch (GroupTreeResolver.GroupTreeResolveException gre) { + Assert.assertEquals("Group 'group3' referenced as member of group 'group2' doesn't exists", gre.getMessage()); + } + } +} diff --git a/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPDnTest.java b/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPDnTest.java new file mode 100644 index 0000000000..566d6c9efb --- /dev/null +++ b/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPDnTest.java @@ -0,0 +1,49 @@ +/* + * 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.storage.ldap.idm.model; + +import org.junit.Assert; +import org.junit.Test; + +/** + * @author Marek Posolda + */ +public class LDAPDnTest { + + @Test + public void testDn() throws Exception { + LDAPDn dn = LDAPDn.fromString("dc=keycloak, dc=org"); + dn.addFirst("ou", "People"); + Assert.assertEquals("ou=People,dc=keycloak,dc=org", dn.toString()); + + dn.addFirst("uid", "Johny,Depp+Pepp"); + Assert.assertEquals("uid=Johny\\,Depp\\+Pepp,ou=People,dc=keycloak,dc=org", dn.toString()); + Assert.assertEquals(LDAPDn.fromString("uid=Johny\\,Depp\\+Pepp,ou=People,dc=keycloak,dc=org"), dn); + + Assert.assertEquals("ou=People,dc=keycloak,dc=org", dn.getParentDn()); + + Assert.assertTrue(dn.isDescendantOf(LDAPDn.fromString("dc=keycloak, dc=org"))); + Assert.assertTrue(dn.isDescendantOf(LDAPDn.fromString("dc=org"))); + Assert.assertTrue(dn.isDescendantOf(LDAPDn.fromString("DC=keycloak, DC=org"))); + Assert.assertFalse(dn.isDescendantOf(LDAPDn.fromString("dc=keycloakk, dc=org"))); + Assert.assertFalse(dn.isDescendantOf(dn)); + + Assert.assertEquals("uid", dn.getFirstRdnAttrName()); + Assert.assertEquals("Johny\\,Depp\\+Pepp", dn.getFirstRdnAttrValue()); + } +} diff --git a/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPMappersComparatorTest.java b/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPMappersComparatorTest.java new file mode 100644 index 0000000000..41bf3ec9b0 --- /dev/null +++ b/federation/ldap2/src/test/java/org/keycloak/storage/ldap/idm/model/LDAPMappersComparatorTest.java @@ -0,0 +1,116 @@ +/* + * 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.storage.ldap.idm.model; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.mappers.FullNameLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.FullNameLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.LDAPMappersComparator; +import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapperFactory; + +import java.util.LinkedList; +import java.util.List; + +/** + * @author Marek Posolda + */ +public class LDAPMappersComparatorTest { + + + + @Test + public void testCompareWithCNUsername() { + MultivaluedHashMap cfg = new MultivaluedHashMap<>(); + cfg.add(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, LDAPConstants.CN); + LDAPConfig config = new LDAPConfig(cfg); + + List sorted = LDAPMappersComparator.sortAsc(config, getMappers()); + assertOrder(sorted, "username-cn", "sAMAccountName", "first name", "full name"); + + sorted = LDAPMappersComparator.sortDesc(config, getMappers()); + assertOrder(sorted, "full name", "first name", "sAMAccountName", "username-cn"); + } + + @Test + public void testCompareWithSAMAccountNameUsername() { + MultivaluedHashMap cfg = new MultivaluedHashMap<>(); + cfg.add(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, LDAPConstants.SAM_ACCOUNT_NAME); + LDAPConfig config = new LDAPConfig(cfg); + + List sorted = LDAPMappersComparator.sortAsc(config, getMappers()); + assertOrder(sorted, "sAMAccountName", "username-cn", "first name", "full name"); + + sorted = LDAPMappersComparator.sortDesc(config, getMappers()); + assertOrder(sorted, "full name", "first name", "username-cn", "sAMAccountName"); + } + + private void assertOrder(List result, String... names) { + Assert.assertEquals(result.size(), names.length); + for (int i=0 ; i getMappers() { + List result = new LinkedList<>(); + + ComponentModel mapperModel = KeycloakModelUtils.createComponentModel("first name", "fed-provider", UserAttributeLDAPStorageMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.FIRST_NAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.GIVENNAME, + UserAttributeLDAPStorageMapper.READ_ONLY, "true", + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "true", + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + mapperModel.setId("idd1"); + result.add(mapperModel); + + mapperModel = KeycloakModelUtils.createComponentModel("username-cn", "fed-provider", UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.USERNAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.CN, + UserAttributeLDAPStorageMapper.READ_ONLY, "true", + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "false", + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + mapperModel.setId("idd2"); + result.add(mapperModel); + + mapperModel = KeycloakModelUtils.createComponentModel("full name", "fed-provider", FullNameLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + FullNameLDAPStorageMapper.LDAP_FULL_NAME_ATTRIBUTE, LDAPConstants.CN, + UserAttributeLDAPStorageMapper.READ_ONLY, "true"); + mapperModel.setId("idd3"); + result.add(mapperModel); + + mapperModel = KeycloakModelUtils.createComponentModel("sAMAccountName", "fed-provider", UserAttributeLDAPStorageMapperFactory.PROVIDER_ID,LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, UserModel.USERNAME, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, LDAPConstants.SAM_ACCOUNT_NAME, + UserAttributeLDAPStorageMapper.READ_ONLY, "false", + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "false", + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + mapperModel.setId("idd4"); + result.add(mapperModel); + + return result; + } +} diff --git a/federation/pom.xml b/federation/pom.xml index 7942feed2e..1318643b8d 100755 --- a/federation/pom.xml +++ b/federation/pom.xml @@ -33,8 +33,9 @@ - ldap kerberos + ldap + ldap2 sssd diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index bd03727522..b0ea73a6cc 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -48,6 +48,7 @@ import org.keycloak.models.jpa.entities.UserConsentRoleEntity; import org.keycloak.models.jpa.entities.UserEntity; import org.keycloak.models.utils.DefaultRoles; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.storage.UserStorageProvider; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; @@ -395,33 +396,38 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore { @Override public void preRemove(RealmModel realm, UserFederationProviderModel link) { + String linkId = link.getId(); + removeUserDataByLink(realm, linkId); + } + + public void removeUserDataByLink(RealmModel realm, String linkId) { int num = em.createNamedQuery("deleteUserRoleMappingsByRealmAndLink") .setParameter("realmId", realm.getId()) - .setParameter("link", link.getId()) + .setParameter("link", linkId) .executeUpdate(); num = em.createNamedQuery("deleteUserRequiredActionsByRealmAndLink") .setParameter("realmId", realm.getId()) - .setParameter("link", link.getId()) + .setParameter("link", linkId) .executeUpdate(); num = em.createNamedQuery("deleteFederatedIdentityByRealmAndLink") .setParameter("realmId", realm.getId()) - .setParameter("link", link.getId()) + .setParameter("link", linkId) .executeUpdate(); num = em.createNamedQuery("deleteCredentialAttributeByRealmAndLink") .setParameter("realmId", realm.getId()) - .setParameter("link", link.getId()) + .setParameter("link", linkId) .executeUpdate(); num = em.createNamedQuery("deleteCredentialsByRealmAndLink") .setParameter("realmId", realm.getId()) - .setParameter("link", link.getId()) + .setParameter("link", linkId) .executeUpdate(); num = em.createNamedQuery("deleteUserAttributesByRealmAndLink") .setParameter("realmId", realm.getId()) - .setParameter("link", link.getId()) + .setParameter("link", linkId) .executeUpdate(); num = em.createNamedQuery("deleteUsersByRealmAndLink") .setParameter("realmId", realm.getId()) - .setParameter("link", link.getId()) + .setParameter("link", linkId) .executeUpdate(); } @@ -718,6 +724,8 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore { @Override public void preRemove(RealmModel realm, ComponentModel component) { + if (!component.getProviderType().equals(UserStorageProvider.class.getName())) return; + removeUserDataByLink(realm, component.getId()); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index ebfa5bc2e9..b8670dc115 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -2036,7 +2036,7 @@ public class RealmAdapter implements RealmModel, JpaModel { throw new IllegalArgumentException("Invalid component type"); } - componentFactory.validateConfiguration(session, model); + componentFactory.validateConfiguration(session, this, model); ComponentEntity c = new ComponentEntity(); if (model.getId() == null) { @@ -2046,6 +2046,7 @@ public class RealmAdapter implements RealmModel, JpaModel { } c.setName(model.getName()); c.setParentId(model.getParentId()); + if (model.getParentId() == null) c.setParentId(this.getId()); c.setProviderType(model.getProviderType()); c.setProviderId(model.getProviderId()); c.setSubType(model.getSubType()); @@ -2077,7 +2078,7 @@ public class RealmAdapter implements RealmModel, JpaModel { @Override public void updateComponent(ComponentModel component) { - ComponentUtil.getComponentFactory(session, component).validateConfiguration(session, component); + ComponentUtil.getComponentFactory(session, component).validateConfiguration(session, this, component); ComponentEntity c = em.find(ComponentEntity.class, component.getId()); if (c == null) return; @@ -2098,6 +2099,7 @@ public class RealmAdapter implements RealmModel, JpaModel { ComponentEntity c = em.find(ComponentEntity.class, component.getId()); if (c == null) return; session.users().preRemove(this, component); + removeComponents(component.getId()); em.createNamedQuery("deleteComponentConfigByComponent").setParameter("component", c).executeUpdate(); em.remove(c); } diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java index e60055bb6d..2fa756192d 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java @@ -1954,7 +1954,7 @@ public class RealmAdapter extends AbstractMongoAdapter impleme @Override public ComponentModel addComponentModel(ComponentModel model) { - ComponentUtil.getComponentFactory(session, model).validateConfiguration(session, model); + ComponentUtil.getComponentFactory(session, model).validateConfiguration(session, this, model); ComponentEntity entity = new ComponentEntity(); if (model.getId() == null) { @@ -1964,6 +1964,7 @@ public class RealmAdapter extends AbstractMongoAdapter impleme } updateComponentEntity(entity, model); model.setId(entity.getId()); + if (model.getParentId() == null) entity.setParentId(this.getId()); realm.getComponentEntities().add(entity); updateRealm(); ComponentUtil.notifyCreated(session, this, model); @@ -1972,7 +1973,7 @@ public class RealmAdapter extends AbstractMongoAdapter impleme @Override public void updateComponent(ComponentModel model) { - ComponentUtil.getComponentFactory(session, model).validateConfiguration(session, model); + ComponentUtil.getComponentFactory(session, model).validateConfiguration(session, this, model); for (ComponentEntity entity : realm.getComponentEntities()) { if (entity.getId().equals(model.getId())) { @@ -1999,6 +2000,7 @@ public class RealmAdapter extends AbstractMongoAdapter impleme while(it.hasNext()) { if (it.next().getId().equals(component.getId())) { session.users().preRemove(this, component); + removeComponents(component.getId()); it.remove(); break; } diff --git a/pom.xml b/pom.xml index a5e376dd1e..db1a8d161e 100755 --- a/pom.xml +++ b/pom.xml @@ -705,6 +705,11 @@ keycloak-ldap-federation ${project.version} + + org.keycloak + keycloak-ldap-storage + ${project.version} + org.keycloak keycloak-dependencies-server-min diff --git a/server-spi/src/main/java/org/keycloak/component/ComponentFactory.java b/server-spi/src/main/java/org/keycloak/component/ComponentFactory.java index c38875636b..75c3fb130e 100644 --- a/server-spi/src/main/java/org/keycloak/component/ComponentFactory.java +++ b/server-spi/src/main/java/org/keycloak/component/ComponentFactory.java @@ -38,7 +38,7 @@ public interface ComponentFactory ex return null; } - void validateConfiguration(KeycloakSession session, ComponentModel model) throws ComponentValidationException; + void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException; default void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) { diff --git a/server-spi/src/main/java/org/keycloak/component/ComponentModel.java b/server-spi/src/main/java/org/keycloak/component/ComponentModel.java index dc5c68010a..f6c26d8d34 100755 --- a/server-spi/src/main/java/org/keycloak/component/ComponentModel.java +++ b/server-spi/src/main/java/org/keycloak/component/ComponentModel.java @@ -48,7 +48,7 @@ public class ComponentModel implements Serializable { this.providerType = copy.providerType; this.parentId = copy.parentId; this.subType = copy.subType; - this.config = copy.config; + this.config.addAll(copy.config); } diff --git a/server-spi/src/main/java/org/keycloak/component/SubComponentFactory.java b/server-spi/src/main/java/org/keycloak/component/SubComponentFactory.java new file mode 100644 index 0000000000..3f5295da63 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/component/SubComponentFactory.java @@ -0,0 +1,40 @@ +/* + * 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.component; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderFactory; + +import java.util.Collections; +import java.util.List; + +/** + * Useful when you want to describe config properties that are effected by the parent ComponentModel + * + * @author Bill Burke + * @version $Revision: 1 $ + */ +public interface SubComponentFactory extends ComponentFactory { + default + List getConfigProperties(RealmModel realm, ComponentModel parent) { + return getConfigProperties(); + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/server-spi/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java index 7edfea5a14..a962300249 100755 --- a/server-spi/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java +++ b/server-spi/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java @@ -372,6 +372,29 @@ public final class KeycloakModelUtils { } return null; } + public static ComponentModel createComponentModel(String name, String parentId, String providerId, String providerType, String... config) { + ComponentModel mapperModel = new ComponentModel(); + mapperModel.setParentId(parentId); + mapperModel.setName(name); + mapperModel.setProviderId(providerId); + mapperModel.setProviderType(providerType); + + String key = null; + for (String configEntry : config) { + if (key == null) { + key = configEntry; + } else { + mapperModel.getConfig().add(key, configEntry); + key = null; + } + } + if (key != null) { + throw new IllegalStateException("Invalid count of arguments for config. Maybe mistake?"); + } + + return mapperModel; + } + public static UserFederationMapperModel createUserFederationMapperModel(String name, String federationProviderId, String mapperType, String... config) { diff --git a/server-spi/src/main/java/org/keycloak/provider/ProviderConfigurationBuilder.java b/server-spi/src/main/java/org/keycloak/provider/ProviderConfigurationBuilder.java index 607a41b326..194e8e8fce 100644 --- a/server-spi/src/main/java/org/keycloak/provider/ProviderConfigurationBuilder.java +++ b/server-spi/src/main/java/org/keycloak/provider/ProviderConfigurationBuilder.java @@ -102,6 +102,12 @@ public class ProviderConfigurationBuilder { return this; } + public ProviderConfigPropertyBuilder options(List options) { + this.options = options; + return this; + } + + public ProviderConfigPropertyBuilder secret(boolean secret) { this.secret = secret; return this; diff --git a/server-spi/src/main/java/org/keycloak/storage/UserStorageProviderFactory.java b/server-spi/src/main/java/org/keycloak/storage/UserStorageProviderFactory.java index 121a5ea367..ba47784d6e 100755 --- a/server-spi/src/main/java/org/keycloak/storage/UserStorageProviderFactory.java +++ b/server-spi/src/main/java/org/keycloak/storage/UserStorageProviderFactory.java @@ -81,7 +81,7 @@ public interface UserStorageProviderFactory exten } @Override - default void validateConfiguration(KeycloakSession session, ComponentModel config) throws ComponentValidationException { + default void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { } diff --git a/services/src/main/java/org/keycloak/keys/AbstractRsaKeyProviderFactory.java b/services/src/main/java/org/keycloak/keys/AbstractRsaKeyProviderFactory.java index 7b2d526086..1c2af4fcab 100644 --- a/services/src/main/java/org/keycloak/keys/AbstractRsaKeyProviderFactory.java +++ b/services/src/main/java/org/keycloak/keys/AbstractRsaKeyProviderFactory.java @@ -20,6 +20,7 @@ package org.keycloak.keys; import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigurationBuilder; @@ -36,7 +37,7 @@ public abstract class AbstractRsaKeyProviderFactory implements KeyProviderFactor } @Override - public void validateConfiguration(KeycloakSession session, ComponentModel model) throws ComponentValidationException { + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { ConfigurationValidationHelper.check(model) .checkLong(Attributes.PRIORITY_PROPERTY, false) .checkBoolean(Attributes.ENABLED_PROPERTY, false) diff --git a/services/src/main/java/org/keycloak/keys/GeneratedRsaKeyProviderFactory.java b/services/src/main/java/org/keycloak/keys/GeneratedRsaKeyProviderFactory.java index 9042b48989..cba36d0b5d 100644 --- a/services/src/main/java/org/keycloak/keys/GeneratedRsaKeyProviderFactory.java +++ b/services/src/main/java/org/keycloak/keys/GeneratedRsaKeyProviderFactory.java @@ -57,8 +57,8 @@ public class GeneratedRsaKeyProviderFactory extends AbstractRsaKeyProviderFactor } @Override - public void validateConfiguration(KeycloakSession session, ComponentModel model) throws ComponentValidationException { - super.validateConfiguration(session, model); + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { + super.validateConfiguration(session, realm, model); ConfigurationValidationHelper.check(model) .checkInt(Attributes.KEY_SIZE_PROPERTY, false); @@ -75,7 +75,6 @@ public class GeneratedRsaKeyProviderFactory extends AbstractRsaKeyProviderFactor } if (!(model.contains(Attributes.PRIVATE_KEY_KEY) && model.contains(Attributes.CERTIFICATE_KEY))) { - RealmModel realm = session.realms().getRealm(model.getParentId()); generateKeys(realm, model, size); logger.debugv("Generated keys for {0}", realm.getName()); @@ -83,7 +82,6 @@ public class GeneratedRsaKeyProviderFactory extends AbstractRsaKeyProviderFactor PrivateKey privateKey = PemUtils.decodePrivateKey(model.get(Attributes.PRIVATE_KEY_KEY)); int currentSize = ((RSAPrivateKey) privateKey).getModulus().bitLength(); if (currentSize != size) { - RealmModel realm = session.realms().getRealm(model.getParentId()); generateKeys(realm, model, size); logger.debugv("Key size changed, generating new keys for {0}", realm.getName()); diff --git a/services/src/main/java/org/keycloak/keys/JavaKeystoreKeyProviderFactory.java b/services/src/main/java/org/keycloak/keys/JavaKeystoreKeyProviderFactory.java index 518d321783..e6ed9848dd 100644 --- a/services/src/main/java/org/keycloak/keys/JavaKeystoreKeyProviderFactory.java +++ b/services/src/main/java/org/keycloak/keys/JavaKeystoreKeyProviderFactory.java @@ -22,6 +22,7 @@ import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigProperty; @@ -63,8 +64,8 @@ public class JavaKeystoreKeyProviderFactory extends AbstractRsaKeyProviderFactor } @Override - public void validateConfiguration(KeycloakSession session, ComponentModel model) throws ComponentValidationException { - super.validateConfiguration(session, model); + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { + super.validateConfiguration(session, realm, model); ConfigurationValidationHelper.check(model) .checkSingle(KEYSTORE_PROPERTY, true) diff --git a/services/src/main/java/org/keycloak/keys/RsaKeyProviderFactory.java b/services/src/main/java/org/keycloak/keys/RsaKeyProviderFactory.java index f6814dac9d..aac9fbd1df 100644 --- a/services/src/main/java/org/keycloak/keys/RsaKeyProviderFactory.java +++ b/services/src/main/java/org/keycloak/keys/RsaKeyProviderFactory.java @@ -56,8 +56,8 @@ public class RsaKeyProviderFactory extends AbstractRsaKeyProviderFactory { } @Override - public void validateConfiguration(KeycloakSession session, ComponentModel model) throws ComponentValidationException { - super.validateConfiguration(session, model); + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { + super.validateConfiguration(session, realm, model); ConfigurationValidationHelper.check(model) .checkSingle(Attributes.PRIVATE_KEY_PROPERTY, true) @@ -89,7 +89,6 @@ public class RsaKeyProviderFactory extends AbstractRsaKeyProviderFactory { } } else { try { - RealmModel realm = session.realms().getRealm(model.getParentId()); Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate(keyPair, realm.getName()); model.put(Attributes.CERTIFICATE_KEY, PemUtils.encodeCertificate(certificate)); } catch (Throwable t) { diff --git a/services/src/main/java/org/keycloak/services/clientregistration/policy/AbstractClientRegistrationPolicyFactory.java b/services/src/main/java/org/keycloak/services/clientregistration/policy/AbstractClientRegistrationPolicyFactory.java index 572a7120f5..e29848c60b 100644 --- a/services/src/main/java/org/keycloak/services/clientregistration/policy/AbstractClientRegistrationPolicyFactory.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/policy/AbstractClientRegistrationPolicyFactory.java @@ -24,6 +24,7 @@ import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; import org.keycloak.provider.ProviderConfigProperty; /** @@ -47,7 +48,7 @@ public abstract class AbstractClientRegistrationPolicyFactory implements ClientR } @Override - public void validateConfiguration(KeycloakSession session, ComponentModel config) throws ComponentValidationException { + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { } @Override diff --git a/services/src/main/java/org/keycloak/services/clientregistration/policy/impl/MaxClientsClientRegistrationPolicyFactory.java b/services/src/main/java/org/keycloak/services/clientregistration/policy/impl/MaxClientsClientRegistrationPolicyFactory.java index 00ebe05c3a..120f2808af 100644 --- a/services/src/main/java/org/keycloak/services/clientregistration/policy/impl/MaxClientsClientRegistrationPolicyFactory.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/policy/impl/MaxClientsClientRegistrationPolicyFactory.java @@ -23,6 +23,7 @@ import java.util.List; import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.services.clientregistration.policy.AbstractClientRegistrationPolicyFactory; @@ -72,7 +73,7 @@ public class MaxClientsClientRegistrationPolicyFactory extends AbstractClientReg } @Override - public void validateConfiguration(KeycloakSession session, ComponentModel config) throws ComponentValidationException { + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { ConfigurationValidationHelper.check(config) .checkInt(MAX_CLIENTS_PROPERTY, true); } diff --git a/services/src/main/java/org/keycloak/services/clientregistration/policy/impl/TrustedHostClientRegistrationPolicyFactory.java b/services/src/main/java/org/keycloak/services/clientregistration/policy/impl/TrustedHostClientRegistrationPolicyFactory.java index a5436c12a2..c881f64563 100644 --- a/services/src/main/java/org/keycloak/services/clientregistration/policy/impl/TrustedHostClientRegistrationPolicyFactory.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/policy/impl/TrustedHostClientRegistrationPolicyFactory.java @@ -23,6 +23,7 @@ import java.util.List; import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.services.clientregistration.policy.AbstractClientRegistrationPolicyFactory; @@ -70,7 +71,7 @@ public class TrustedHostClientRegistrationPolicyFactory extends AbstractClientRe } @Override - public void validateConfiguration(KeycloakSession session, ComponentModel config) throws ComponentValidationException { + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { ConfigurationValidationHelper.check(config) .checkBoolean(HOST_SENDING_REGISTRATION_REQUEST_MUST_MATCH_PROPERTY, true) .checkBoolean(CLIENT_URIS_MUST_MATCH_PROPERTY, true); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ComponentResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ComponentResource.java index 5919b2d61d..bbd2edb095 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/ComponentResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ComponentResource.java @@ -22,13 +22,19 @@ import org.jboss.resteasy.spi.NotFoundException; import org.keycloak.common.ClientConnection; import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; +import org.keycloak.component.SubComponentFactory; import org.keycloak.events.admin.OperationType; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.models.utils.StripSecretsUtils; +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderFactory; import org.keycloak.representations.idm.ComponentRepresentation; +import org.keycloak.representations.idm.ComponentTypeRepresentation; +import org.keycloak.representations.idm.ConfigPropertyRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.ErrorResponseException; @@ -197,4 +203,32 @@ public class ComponentResource { return ErrorResponse.error(message, Response.Status.BAD_REQUEST); } + @GET + @Path("{id}/sub-component-config") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + public List getSubcomponentConfig(@PathParam("id") String id, @QueryParam("type") String providerType, @QueryParam("id") String providerId) { + auth.requireView(); + ComponentModel parent = realm.getComponent(id); + if (parent == null) { + throw new NotFoundException("Could not find component"); + } + Class providerClass = null; + try { + providerClass = (Class)Class.forName(providerType); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + ProviderFactory factory = session.getKeycloakSessionFactory().getProviderFactory(providerClass, providerId); + if (factory == null) { + throw new NotFoundException("Could not find subcomponent factory"); + + } + if (!(factory instanceof SubComponentFactory)) return Collections.EMPTY_LIST; + List props = ((SubComponentFactory)factory).getConfigProperties(realm, parent); + return ModelToRepresentation.toRepresentation(props); + } + + + } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/components/TestImplProviderFactory.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/components/TestImplProviderFactory.java index 4092501300..b3de69ee3c 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/components/TestImplProviderFactory.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/components/TestImplProviderFactory.java @@ -22,6 +22,7 @@ import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; import org.keycloak.provider.ConfigurationValidationHelper; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; @@ -50,7 +51,7 @@ public class TestImplProviderFactory implements TestProviderFactory { } @Override - public void validateConfiguration(KeycloakSession session, ComponentModel model) throws ComponentValidationException { + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException { ConfigurationValidationHelper.check(model) .checkRequired("required", "Required") .checkInt("number", "Number", false); diff --git a/testsuite/integration/pom.xml b/testsuite/integration/pom.xml index d1311b59ea..8aa1afb5b2 100755 --- a/testsuite/integration/pom.xml +++ b/testsuite/integration/pom.xml @@ -126,6 +126,10 @@ org.keycloak keycloak-ldap-federation + + org.keycloak + keycloak-ldap-storage + org.keycloak keycloak-kerberos-federation diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPExampleServlet.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPExampleServlet.java new file mode 100644 index 0000000000..f154b47a5d --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPExampleServlet.java @@ -0,0 +1,63 @@ +/* + * 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.testsuite.federation.storage.ldap; + +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.representations.IDToken; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Map; + +/** + * @author Marek Posolda + */ +public class LDAPExampleServlet extends HttpServlet { + + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + KeycloakSecurityContext securityContext = (KeycloakSecurityContext) req.getAttribute(KeycloakSecurityContext.class.getName()); + IDToken idToken = securityContext.getIdToken(); + + PrintWriter out = resp.getWriter(); + out.println("LDAP Portal"); + out.println(""); + + out.printf("", "preferred_username", idToken.getPreferredUsername()); + out.println(); + out.printf("", "name", idToken.getName()); + out.println(); + out.printf("", "email", idToken.getEmail()); + out.println(); + + for (Map.Entry claim : idToken.getOtherClaims().entrySet()) { + String value = claim.getValue().toString(); + out.printf("", claim.getKey(), value); + out.println(); + } + + out.println("
Attribute nameAttribute values
%s%s
%s%s
%s%s
%s%s
"); + out.flush(); + } + +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPProvidersIntegrationTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPProvidersIntegrationTest.java new file mode 100755 index 0000000000..85f7812f57 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPProvidersIntegrationTest.java @@ -0,0 +1,868 @@ +/* + * 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.testsuite.federation.storage.ldap; + +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; +import org.junit.runners.MethodSorters; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.component.ComponentModel; +import org.keycloak.credential.CredentialModel; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.UserStorageProviderModel; +import org.keycloak.storage.ldap.LDAPConfig; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.models.ModelReadOnlyException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.representations.AccessToken; +import org.keycloak.services.managers.RealmManager; +import org.keycloak.storage.ldap.mappers.FullNameLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.FullNameLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.HardcodedLDAPRoleStorageMapper; +import org.keycloak.storage.ldap.mappers.HardcodedLDAPRoleStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapper; +import org.keycloak.testsuite.OAuthClient; +import org.keycloak.testsuite.pages.AccountPasswordPage; +import org.keycloak.testsuite.pages.AccountUpdateProfilePage; +import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.RegisterPage; +import org.keycloak.testsuite.rule.KeycloakRule; +import org.keycloak.testsuite.rule.LDAPRule; +import org.keycloak.testsuite.rule.WebResource; +import org.keycloak.testsuite.rule.WebRule; +import org.openqa.selenium.WebDriver; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +/** + * @author Marek Posolda + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class LDAPProvidersIntegrationTest { + + private static LDAPRule ldapRule = new LDAPRule(); + + private static ComponentModel ldapModel = null; + + private static MultivaluedHashMap getLdapRuleConfig() { + MultivaluedHashMap config = new MultivaluedHashMap<>(); + Map ldapConfig = ldapRule.getConfig(); + for (Map.Entry entry : ldapConfig.entrySet()) { + config.add(entry.getKey(), entry.getValue()); + + } + return config; + + } + + private static KeycloakRule keycloakRule = new KeycloakRule(new KeycloakRule.KeycloakSetup() { + + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + LDAPTestUtils.addLocalUser(manager.getSession(), appRealm, "marykeycloak", "mary@test.com", "password-app"); + + MultivaluedHashMap ldapConfig = getLdapRuleConfig(); + ldapConfig.putSingle(LDAPConstants.SYNC_REGISTRATIONS, "true"); + ldapConfig.putSingle(LDAPConstants.EDIT_MODE, LDAPStorageProviderFactory.EditMode.WRITABLE.toString()); + UserStorageProviderModel model = new UserStorageProviderModel(); + model.setLastSync(0); + model.setChangedSyncPeriod(-1); + model.setFullSyncPeriod(-1); + model.setName("test-ldap"); + model.setPriority(0); + model.setProviderId(LDAPStorageProviderFactory.PROVIDER_NAME); + model.setConfig(ldapConfig); + + ldapModel = appRealm.addComponentModel(model); + LDAPTestUtils.addZipCodeLDAPMapper(appRealm, ldapModel); + + // Delete all LDAP users and add some new for testing + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPTestUtils.removeAllLDAPUsers(ldapFedProvider, appRealm); + + LDAPObject john = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "johnkeycloak", "John", "Doe", "john@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, john, "Password1"); + + LDAPObject existing = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "existing", "Existing", "Foo", "existing@email.org", null, "5678"); + + appRealm.getClientByClientId("test-app").setDirectAccessGrantsEnabled(true); + } + }); + + @ClassRule + public static TestRule chain = RuleChain + .outerRule(ldapRule) + .around(keycloakRule); + + @Rule + public WebRule webRule = new WebRule(this); + + @WebResource + protected OAuthClient oauth; + + @WebResource + protected WebDriver driver; + + @WebResource + protected AppPage appPage; + + @WebResource + protected RegisterPage registerPage; + + @WebResource + protected LoginPage loginPage; + + @WebResource + protected AccountUpdateProfilePage profilePage; + + @WebResource + protected AccountPasswordPage changePasswordPage; + +// @Test +// @Ignore +// public void runit() throws Exception { +// Thread.sleep(10000000); +// +// } + + + @Test + public void caseInSensitiveImport() { + KeycloakSession session = keycloakRule.startSession(); + try { + RealmManager manager = new RealmManager(session); + RealmModel appRealm = manager.getRealm("test"); + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPObject jbrown2 = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "JBrown2", "John", "Brown2", "jbrown2@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, jbrown2, "Password1"); + LDAPObject jbrown3 = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "jbrown3", "John", "Brown3", "JBrown3@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, jbrown3, "Password1"); + } finally { + keycloakRule.stopSession(session, true); + } + + loginSuccessAndLogout("jbrown2", "Password1"); + loginSuccessAndLogout("JBrown2", "Password1"); + loginSuccessAndLogout("jbrown2@email.org", "Password1"); + loginSuccessAndLogout("JBrown2@email.org", "Password1"); + + loginSuccessAndLogout("jbrown3", "Password1"); + loginSuccessAndLogout("JBrown3", "Password1"); + loginSuccessAndLogout("jbrown3@email.org", "Password1"); + loginSuccessAndLogout("JBrown3@email.org", "Password1"); + } + + private void loginSuccessAndLogout(String username, String password) { + loginPage.open(); + loginPage.login(username, password); + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + oauth.openLogout(); + } + + @Test + public void caseInsensitiveSearch() { + KeycloakSession session = keycloakRule.startSession(); + try { + RealmManager manager = new RealmManager(session); + RealmModel appRealm = manager.getRealm("test"); + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPObject jbrown4 = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "JBrown4", "John", "Brown4", "jbrown4@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, jbrown4, "Password1"); + LDAPObject jbrown5 = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "jbrown5", "John", "Brown5", "JBrown5@Email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, jbrown5, "Password1"); + } finally { + keycloakRule.stopSession(session, true); + } + + session = keycloakRule.startSession(); + try { + RealmManager manager = new RealmManager(session); + RealmModel appRealm = manager.getRealm("test"); + + // search by username + List users = session.users().searchForUser("JBROwn4", appRealm); + Assert.assertEquals(1, users.size()); + UserModel user4 = users.get(0); + Assert.assertEquals("jbrown4", user4.getUsername()); + Assert.assertEquals("jbrown4@email.org", user4.getEmail()); + + // search by email + users = session.users().searchForUser("JBROwn5@eMAil.org", appRealm); + Assert.assertEquals(1, users.size()); + UserModel user5 = users.get(0); + Assert.assertEquals("jbrown5", user5.getUsername()); + Assert.assertEquals("jbrown5@email.org", user5.getEmail()); + } finally { + keycloakRule.stopSession(session, true); + } + } + + @Test + public void deleteFederationLink() { + loginLdap(); + { + KeycloakSession session = keycloakRule.startSession(); + try { + RealmManager manager = new RealmManager(session); + + RealmModel appRealm = manager.getRealm("test"); + appRealm.removeComponent(ldapModel); + Assert.assertEquals(0, appRealm.getComponents(appRealm.getId(), UserStorageProvider.class.getName()).size()); + } finally { + keycloakRule.stopSession(session, true); + } + } + loginPage.open(); + loginPage.login("johnkeycloak", "Password1"); + loginPage.assertCurrent(); + + Assert.assertEquals("Invalid username or password.", loginPage.getError()); + + { + KeycloakSession session = keycloakRule.startSession(); + try { + RealmManager manager = new RealmManager(session); + + RealmModel appRealm = manager.getRealm("test"); + ldapModel.setId(null); + ldapModel = appRealm.addComponentModel(ldapModel); + LDAPTestUtils.addZipCodeLDAPMapper(appRealm, ldapModel); + } finally { + keycloakRule.stopSession(session, true); + } + } + loginLdap(); + + } + + @Test + public void loginClassic() { + loginPage.open(); + loginPage.login("marykeycloak", "password-app"); + + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + } + + @Test + public void loginLdap() { + loginPage.open(); + loginPage.login("johnkeycloak", "Password1"); + + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + profilePage.open(); + Assert.assertEquals("John", profilePage.getFirstName()); + Assert.assertEquals("Doe", profilePage.getLastName()); + Assert.assertEquals("john@email.org", profilePage.getEmail()); + } + + @Test + public void loginLdapWithDirectGrant() throws Exception { + OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("password", "johnkeycloak", "Password1"); + assertEquals(200, response.getStatusCode()); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + + response = oauth.doGrantAccessTokenRequest("password", "johnkeycloak", ""); + assertEquals(401, response.getStatusCode()); + } + + @Test + public void loginLdapWithEmail() { + loginPage.open(); + loginPage.login("john@email.org", "Password1"); + + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + } + + @Test + public void loginLdapWithoutPassword() { + loginPage.open(); + loginPage.login("john@email.org", ""); + Assert.assertEquals("Invalid username or password.", loginPage.getError()); + } + + @Test + public void passwordChangeLdap() 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 registerExistingLdapUser() { + loginPage.open(); + loginPage.clickRegister(); + registerPage.assertCurrent(); + + // check existing username + registerPage.register("firstName", "lastName", "email@mail.cz", "existing", "Password1", "Password1"); + registerPage.assertCurrent(); + Assert.assertEquals("Username already exists.", registerPage.getError()); + + // Check existing email + registerPage.register("firstName", "lastName", "existing@email.org", "nonExisting", "Password1", "Password1"); + registerPage.assertCurrent(); + Assert.assertEquals("Email already exists.", registerPage.getError()); + } + + @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()); + + KeycloakSession session = keycloakRule.startSession(); + try { + RealmModel appRealm = session.realms().getRealmByName("test"); + UserModel user = session.users().getUserByUsername("registerUserSuccess2", appRealm); + Assert.assertNotNull(user); + Assert.assertNotNull(user.getFederationLink()); + Assert.assertEquals(user.getFederationLink(), ldapModel.getId()); + } finally { + keycloakRule.stopSession(session, false); + } + } + + @Test + public void testCaseSensitiveAttributeName() { + KeycloakSession session = keycloakRule.startSession(); + + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPObject johnZip = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "johnzip", "John", "Zip", "johnzip@email.org", null, "12398"); + + // Remove default zipcode mapper and add the mapper for "POstalCode" to test case sensitivity + ComponentModel currentZipMapper = LDAPTestUtils.getComponentByName(appRealm, ldapModel, "zipCodeMapper"); + appRealm.removeComponent(currentZipMapper); + LDAPTestUtils.addUserAttributeMapper(appRealm, ldapModel, "zipCodeMapper-cs", "postal_code", "POstalCode"); + + // Fetch user from LDAP and check that postalCode is filled + UserModel user = session.users().getUserByUsername("johnzip", appRealm); + String postalCode = user.getFirstAttribute("postal_code"); + Assert.assertEquals("12398", postalCode); + + } finally { + keycloakRule.stopSession(session, false); + } + } + + @Test + public void testCommaInUsername() { + KeycloakSession session = keycloakRule.startSession(); + boolean skip = false; + + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + + // Workaround as comma is not allowed in sAMAccountName on active directory. So we will skip the test for this configuration + LDAPConfig config = ldapFedProvider.getLdapIdentityStore().getConfig(); + if (config.isActiveDirectory() && config.getUsernameLdapAttribute().equals(LDAPConstants.SAM_ACCOUNT_NAME)) { + skip = true; + } + + if (!skip) { + LDAPObject johnComma = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "john,comma", "John", "Comma", "johncomma@email.org", null, "12387"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, johnComma, "Password1"); + + LDAPObject johnPlus = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "john+plus,comma", "John", "Plus", "johnplus@email.org", null, "12387"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, johnPlus, "Password1"); + } + } finally { + keycloakRule.stopSession(session, false); + } + + if (!skip) { + // Try to import the user with comma in username into Keycloak + loginSuccessAndLogout("john,comma", "Password1"); + loginSuccessAndLogout("john+plus,comma", "Password1"); + } + } + + @Test + public void testDirectLDAPUpdate() { + KeycloakSession session = keycloakRule.startSession(); + + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPObject johnDirect = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "johndirect", "John", "Direct", "johndirect@email.org", null, "12399"); + + // Fetch user from LDAP and check that postalCode is filled + UserModel user = session.users().getUserByUsername("johndirect", appRealm); + String postalCode = user.getFirstAttribute("postal_code"); + Assert.assertEquals("12399", postalCode); + + // Directly update user in LDAP + johnDirect.setSingleAttribute(LDAPConstants.POSTAL_CODE, "12400"); + johnDirect.setSingleAttribute(LDAPConstants.SN, "DirectLDAPUpdated"); + ldapFedProvider.getLdapIdentityStore().update(johnDirect); + + } finally { + keycloakRule.stopSession(session, true); + } + + session = keycloakRule.startSession(); + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + UserModel user = session.users().getUserByUsername("johndirect", appRealm); + + // Verify that postalCode is still the same as we read it's value from Keycloak DB + user = session.users().getUserByUsername("johndirect", appRealm); + String postalCode = user.getFirstAttribute("postal_code"); + Assert.assertEquals("12399", postalCode); + + // Check user.getAttributes() + postalCode = user.getAttributes().get("postal_code").get(0); + Assert.assertEquals("12399", postalCode); + + // LastName is new as lastName mapper will read the value from LDAP + String lastName = user.getLastName(); + Assert.assertEquals("DirectLDAPUpdated", lastName); + } finally { + keycloakRule.stopSession(session, true); + } + + session = keycloakRule.startSession(); + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + + // Update postalCode mapper to always read the value from LDAP + ComponentModel zipMapper = LDAPTestUtils.getComponentByName(appRealm, ldapModel, "zipCodeMapper"); + zipMapper.getConfig().putSingle(UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "true"); + appRealm.updateComponent(zipMapper); + + // Update lastName mapper to read the value from Keycloak DB + ComponentModel lastNameMapper = LDAPTestUtils.getComponentByName(appRealm, ldapModel, "last name"); + lastNameMapper.getConfig().putSingle(UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "false"); + appRealm.updateComponent(lastNameMapper); + + // Verify that postalCode is read from LDAP now + UserModel user = session.users().getUserByUsername("johndirect", appRealm); + String postalCode = user.getFirstAttribute("postal_code"); + Assert.assertEquals("12400", postalCode); + + // Check user.getAttributes() + postalCode = user.getAttributes().get("postal_code").get(0); + Assert.assertEquals("12400", postalCode); + + Assert.assertFalse(user.getAttributes().containsKey(UserModel.LAST_NAME)); + + // lastName is read from Keycloak DB now + String lastName = user.getLastName(); + Assert.assertEquals("Direct", lastName); + + } finally { + keycloakRule.stopSession(session, false); + } + } + + + // TODO: Rather separate test for fullNameMapper to better test all the possibilities + @Test + public void testFullNameMapper() { + KeycloakSession session = keycloakRule.startSession(); + ComponentModel firstNameMapper = null; + + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + + // assert that user "fullnameUser" is not in local DB + Assert.assertNull(session.users().getUserByUsername("fullname", appRealm)); + + // Add the user with some fullName into LDAP directly. Ensure that fullName is saved into "cn" attribute in LDAP (currently mapped to model firstName) + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "fullname", "James Dee", "Dee", "fullname@email.org", null, "4578"); + + // add fullname mapper to the provider and remove "firstNameMapper". For this test, we will simply map full name to the LDAP attribute, which was before firstName ( "givenName" on active directory, "cn" on other LDAP servers) + firstNameMapper = LDAPTestUtils.getComponentByName(appRealm, ldapModel, "first name"); + String ldapFirstNameAttributeName = firstNameMapper.getConfig().getFirst(UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE); + appRealm.removeComponent(firstNameMapper); + + ComponentModel fullNameMapperModel = KeycloakModelUtils.createComponentModel("full name", ldapModel.getId(), FullNameLDAPStorageMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + FullNameLDAPStorageMapper.LDAP_FULL_NAME_ATTRIBUTE, ldapFirstNameAttributeName, + FullNameLDAPStorageMapper.READ_ONLY, "false"); + appRealm.addComponentModel(fullNameMapperModel); + } finally { + keycloakRule.stopSession(session, true); + } + + session = keycloakRule.startSession(); + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + + // Assert user is successfully imported in Keycloak DB now with correct firstName and lastName + LDAPTestUtils.assertUserImported(session.users(), appRealm, "fullname", "James", "Dee", "fullname@email.org", "4578"); + + // change mapper to writeOnly + ComponentModel fullNameMapperModel = LDAPTestUtils.getComponentByName(appRealm, ldapModel, "full name"); + fullNameMapperModel.getConfig().putSingle(FullNameLDAPStorageMapper.WRITE_ONLY, "true"); + appRealm.updateComponent(fullNameMapperModel); + } finally { + keycloakRule.stopSession(session, true); + } + + + // Assert changing user in Keycloak will change him in LDAP too... + session = keycloakRule.startSession(); + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + + UserModel fullnameUser = session.users().getUserByUsername("fullname", appRealm); + fullnameUser.setFirstName("James2"); + fullnameUser.setLastName("Dee2"); + } finally { + keycloakRule.stopSession(session, true); + } + + + // Assert changed user available in Keycloak + session = keycloakRule.startSession(); + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + + // Assert user is successfully imported in Keycloak DB now with correct firstName and lastName + LDAPTestUtils.assertUserImported(session.users(), appRealm, "fullname", "James2", "Dee2", "fullname@email.org", "4578"); + + // Remove "fullnameUser" to assert he is removed from LDAP. Revert mappers to previous state + UserModel fullnameUser = session.users().getUserByUsername("fullname", appRealm); + session.users().removeUser(appRealm, fullnameUser); + + // Revert mappers + ComponentModel fullNameMapperModel = LDAPTestUtils.getComponentByName(appRealm, ldapModel, "full name"); + appRealm.removeComponent(fullNameMapperModel); + + firstNameMapper.setId(null); + appRealm.addComponentModel(firstNameMapper); + } finally { + keycloakRule.stopSession(session, true); + } + } + + @Test + public void testHardcodedRoleMapper() { + KeycloakSession session = keycloakRule.startSession(); + ComponentModel firstNameMapper = null; + + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + RoleModel hardcodedRole = appRealm.addRole("hardcoded-role"); + + // assert that user "johnkeycloak" doesn't have hardcoded role + UserModel john = session.users().getUserByUsername("johnkeycloak", appRealm); + Assert.assertFalse(john.hasRole(hardcodedRole)); + + ComponentModel hardcodedMapperModel = KeycloakModelUtils.createComponentModel("hardcoded role", ldapModel.getId(), HardcodedLDAPRoleStorageMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + HardcodedLDAPRoleStorageMapper.ROLE, "hardcoded-role"); + appRealm.addComponentModel(hardcodedMapperModel); + } finally { + keycloakRule.stopSession(session, true); + } + + session = keycloakRule.startSession(); + try { + RealmModel appRealm = new RealmManager(session).getRealmByName("test"); + RoleModel hardcodedRole = appRealm.getRole("hardcoded-role"); + + // Assert user is successfully imported in Keycloak DB now with correct firstName and lastName + UserModel john = session.users().getUserByUsername("johnkeycloak", appRealm); + Assert.assertTrue(john.hasRole(hardcodedRole)); + + // Can't remove user from hardcoded role + try { + john.deleteRoleMapping(hardcodedRole); + Assert.fail("Didn't expected to remove role mapping"); + } catch (ModelException expected) { + } + + // Revert mappers + ComponentModel hardcodedMapperModel = LDAPTestUtils.getComponentByName(appRealm, ldapModel, "hardcoded role"); + appRealm.removeComponent(hardcodedMapperModel); + } finally { + keycloakRule.stopSession(session, true); + } + } + + @Test + public void testImportExistingUserFromLDAP() throws Exception { + // Add LDAP user with same email like existing model user + keycloakRule.update(new KeycloakRule.KeycloakSetup() { + + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "marykeycloak", "Mary1", "Kelly1", "mary1@email.org", null, "123"); + LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "mary-duplicatemail", "Mary2", "Kelly2", "mary@test.com", null, "123"); + LDAPObject marynoemail = LDAPTestUtils.addLDAPUser(ldapFedProvider, appRealm, "marynoemail", "Mary1", "Kelly1", null, null, "123"); + LDAPTestUtils.updateLDAPPassword(ldapFedProvider, marynoemail, "Password1"); + } + + }); + + // Try to import the duplicated LDAP user into Keycloak + loginPage.open(); + loginPage.login("mary-duplicatemail", "password"); + Assert.assertEquals("Email already exists.", loginPage.getError()); + + loginPage.login("mary1@email.org", "password"); + Assert.assertEquals("Username already exists.", loginPage.getError()); + + loginSuccessAndLogout("marynoemail", "Password1"); + } + + @Test + public void testReadonly() { + KeycloakSession session = keycloakRule.startSession(); + try { + RealmModel appRealm = session.realms().getRealmByName("test"); + + UserStorageProviderModel model = new UserStorageProviderModel(ldapModel); + model.getConfig().putSingle(LDAPConstants.EDIT_MODE, LDAPStorageProviderFactory.EditMode.READ_ONLY.toString()); + appRealm.updateComponent(model); + UserModel user = session.users().getUserByUsername("johnkeycloak", appRealm); + Assert.assertNotNull(user); + Assert.assertNotNull(user.getFederationLink()); + Assert.assertEquals(user.getFederationLink(), ldapModel.getId()); + try { + user.setEmail("error@error.com"); + Assert.fail("should fail"); + } catch (ModelReadOnlyException e) { + + } + try { + user.setLastName("Berk"); + Assert.fail("should fail"); + } catch (ModelReadOnlyException e) { + + } + try { + user.setFirstName("Bilbo"); + Assert.fail("should fail"); + } catch (ModelReadOnlyException e) { + + } + try { + UserCredentialModel cred = UserCredentialModel.password("PoopyPoop1"); + session.userCredentialManager().updateCredential(appRealm, user, cred); + Assert.fail("should fail"); + } catch (ModelReadOnlyException e) { + + } + + Assert.assertTrue(session.users().removeUser(appRealm, user)); + } finally { + keycloakRule.stopSession(session, false); + } + + session = keycloakRule.startSession(); + try { + RealmModel appRealm = session.realms().getRealmByName("test"); + Assert.assertEquals(LDAPStorageProviderFactory.EditMode.WRITABLE.toString(), appRealm.getComponent(ldapModel.getId()).getConfig().getFirst(LDAPConstants.EDIT_MODE)); + } finally { + keycloakRule.stopSession(session, false); + } + } + + @Test + public void testRemoveFederatedUser() { + KeycloakSession session = keycloakRule.startSession(); + try { + RealmModel appRealm = session.realms().getRealmByName("test"); + UserModel user = session.users().getUserByUsername("registerUserSuccess2", appRealm); + Assert.assertNotNull(user); + Assert.assertNotNull(user.getFederationLink()); + Assert.assertEquals(user.getFederationLink(), ldapModel.getId()); + + Assert.assertTrue(session.users().removeUser(appRealm, user)); + Assert.assertNull(session.users().getUserByUsername("registerUserSuccess2", appRealm)); + } finally { + keycloakRule.stopSession(session, true); + } + } + + @Test + public void testSearch() { + KeycloakSession session = keycloakRule.startSession(); + try { + RealmModel appRealm = session.realms().getRealmByName("test"); + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + + LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "username1", "John1", "Doel1", "user1@email.org", null, "121"); + LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "username2", "John2", "Doel2", "user2@email.org", null, "122"); + LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "username3", "John3", "Doel3", "user3@email.org", null, "123"); + LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "username4", "John4", "Doel4", "user4@email.org", null, "124"); + + // Users are not at local store at this moment + Assert.assertNull(session.userStorage().getUserByUsername("username1", appRealm)); + Assert.assertNull(session.userStorage().getUserByUsername("username2", appRealm)); + Assert.assertNull(session.userStorage().getUserByUsername("username3", appRealm)); + Assert.assertNull(session.userStorage().getUserByUsername("username4", appRealm)); + + // search by username + session.users().searchForUser("username1", appRealm); + LDAPTestUtils.assertUserImported(session.userStorage(), appRealm, "username1", "John1", "Doel1", "user1@email.org", "121"); + + // search by email + session.users().searchForUser("user2@email.org", appRealm); + LDAPTestUtils.assertUserImported(session.userStorage(), appRealm, "username2", "John2", "Doel2", "user2@email.org", "122"); + + // search by lastName + session.users().searchForUser("Doel3", appRealm); + LDAPTestUtils.assertUserImported(session.userStorage(), appRealm, "username3", "John3", "Doel3", "user3@email.org", "123"); + + // search by firstName + lastName + session.users().searchForUser("John4 Doel4", appRealm); + LDAPTestUtils.assertUserImported(session.userStorage(), appRealm, "username4", "John4", "Doel4", "user4@email.org", "124"); + } finally { + keycloakRule.stopSession(session, true); + } + } + + @Test + public void testSearchWithCustomLDAPFilter() { + // Add custom filter for searching users + KeycloakSession session = keycloakRule.startSession(); + try { + RealmModel appRealm = session.realms().getRealmByName("test"); + ldapModel.getConfig().putSingle(LDAPConstants.CUSTOM_USER_SEARCH_FILTER, "(|(mail=user5@email.org)(mail=user6@email.org))"); + appRealm.updateComponent(ldapModel); + } finally { + keycloakRule.stopSession(session, true); + } + + session = keycloakRule.startSession(); + try { + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + RealmModel appRealm = session.realms().getRealmByName("test"); + + LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "username5", "John5", "Doel5", "user5@email.org", null, "125"); + LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "username6", "John6", "Doel6", "user6@email.org", null, "126"); + LDAPTestUtils.addLDAPUser(ldapProvider, appRealm, "username7", "John7", "Doel7", "user7@email.org", null, "127"); + + // search by email + session.users().searchForUser("user5@email.org", appRealm); + LDAPTestUtils.assertUserImported(session.userStorage(), appRealm, "username5", "John5", "Doel5", "user5@email.org", "125"); + + session.users().searchForUser("John6 Doel6", appRealm); + LDAPTestUtils.assertUserImported(session.userStorage(), appRealm, "username6", "John6", "Doel6", "user6@email.org", "126"); + + session.users().searchForUser("user7@email.org", appRealm); + session.users().searchForUser("John7 Doel7", appRealm); + Assert.assertNull(session.userStorage().getUserByUsername("username7", appRealm)); + + // Remove custom filter + ldapModel.getConfig().remove(LDAPConstants.CUSTOM_USER_SEARCH_FILTER); + appRealm.updateComponent(ldapModel); + } finally { + keycloakRule.stopSession(session, true); + } + } + + @Test + public void testUnsynced() throws Exception { + KeycloakSession session = keycloakRule.startSession(); + try { + RealmModel appRealm = session.realms().getRealmByName("test"); + + UserStorageProviderModel model = new UserStorageProviderModel(ldapModel); + model.getConfig().putSingle(LDAPConstants.EDIT_MODE, LDAPStorageProviderFactory.EditMode.UNSYNCED.toString()); + appRealm.updateComponent(model); + UserModel user = session.users().getUserByUsername("johnkeycloak", appRealm); + Assert.assertNotNull(user); + Assert.assertNotNull(user.getFederationLink()); + Assert.assertEquals(user.getFederationLink(), ldapModel.getId()); + + UserCredentialModel cred = UserCredentialModel.password("Candycand1"); + session.userCredentialManager().updateCredential(appRealm, user, cred); + CredentialModel userCredentialValueModel = session.userCredentialManager().getStoredCredentialsByType(appRealm, user, CredentialModel.PASSWORD).get(0); + Assert.assertEquals(UserCredentialModel.PASSWORD, userCredentialValueModel.getType()); + Assert.assertTrue(session.userCredentialManager().isValid(appRealm, user, cred)); + + // LDAP password is still unchanged + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, model); + LDAPObject ldapUser = ldapProvider.loadLDAPUserByUsername(appRealm, "johnkeycloak"); + ldapProvider.getLdapIdentityStore().validatePassword(ldapUser, "Password1"); + + // User is deleted just locally + Assert.assertTrue(session.users().removeUser(appRealm, user)); + + // Assert user not available locally, but will be reimported from LDAP once searched + Assert.assertNull(session.userStorage().getUserByUsername("johnkeycloak", appRealm)); + Assert.assertNotNull(session.users().getUserByUsername("johnkeycloak", appRealm)); + } finally { + keycloakRule.stopSession(session, false); + } + + session = keycloakRule.startSession(); + try { + RealmModel appRealm = session.realms().getRealmByName("test"); + Assert.assertEquals(LDAPStorageProviderFactory.EditMode.WRITABLE.toString(), appRealm.getComponent(ldapModel.getId()).getConfig().getFirst(LDAPConstants.EDIT_MODE)); + } finally { + keycloakRule.stopSession(session, false); + } + } + +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPTestConfiguration.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPTestConfiguration.java new file mode 100644 index 0000000000..53a6294934 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPTestConfiguration.java @@ -0,0 +1,149 @@ +/* + * 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.testsuite.federation.storage.ldap; + +import org.jboss.logging.Logger; +import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.models.LDAPConstants; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; + +import java.io.File; +import java.io.InputStream; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * @author Marek Posolda + */ +public class LDAPTestConfiguration { + + private static final Logger log = Logger.getLogger(LDAPTestConfiguration.class); + + private String connectionPropertiesLocation; + private int sleepTime; + private boolean startEmbeddedLdapLerver = true; + private Map config; + + protected static final Map PROP_MAPPINGS = new HashMap(); + protected static final Map DEFAULT_VALUES = new HashMap(); + + static { + PROP_MAPPINGS.put(LDAPConstants.CONNECTION_URL, "idm.test.ldap.connection.url"); + PROP_MAPPINGS.put(LDAPConstants.BASE_DN, "idm.test.ldap.base.dn"); + PROP_MAPPINGS.put(LDAPConstants.USERS_DN, "idm.test.ldap.user.dn.suffix"); + PROP_MAPPINGS.put(LDAPConstants.BIND_DN, "idm.test.ldap.bind.dn"); + PROP_MAPPINGS.put(LDAPConstants.BIND_CREDENTIAL, "idm.test.ldap.bind.credential"); + PROP_MAPPINGS.put(LDAPConstants.VENDOR, "idm.test.ldap.vendor"); + PROP_MAPPINGS.put(LDAPConstants.CONNECTION_POOLING, "idm.test.ldap.connection.pooling"); + PROP_MAPPINGS.put(LDAPConstants.PAGINATION, "idm.test.ldap.pagination"); + PROP_MAPPINGS.put(LDAPConstants.BATCH_SIZE_FOR_SYNC, "idm.test.ldap.batch.size.for.sync"); + PROP_MAPPINGS.put(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, "idm.test.ldap.username.ldap.attribute"); + PROP_MAPPINGS.put(LDAPConstants.RDN_LDAP_ATTRIBUTE, "idm.test.ldap.rdn.ldap.attribute"); + PROP_MAPPINGS.put(LDAPConstants.USER_OBJECT_CLASSES, "idm.test.ldap.user.object.classes"); + PROP_MAPPINGS.put(LDAPConstants.EDIT_MODE, "idm.test.ldap.edit.mode"); + + PROP_MAPPINGS.put(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION, "idm.test.kerberos.allow.kerberos.authentication"); + PROP_MAPPINGS.put(KerberosConstants.KERBEROS_REALM, "idm.test.kerberos.realm"); + PROP_MAPPINGS.put(KerberosConstants.SERVER_PRINCIPAL, "idm.test.kerberos.server.principal"); + PROP_MAPPINGS.put(KerberosConstants.KEYTAB, "idm.test.kerberos.keytab"); + PROP_MAPPINGS.put(KerberosConstants.DEBUG, "idm.test.kerberos.debug"); + PROP_MAPPINGS.put(KerberosConstants.ALLOW_PASSWORD_AUTHENTICATION, "idm.test.kerberos.allow.password.authentication"); + PROP_MAPPINGS.put(KerberosConstants.UPDATE_PROFILE_FIRST_LOGIN, "idm.test.kerberos.update.profile.first.login"); + PROP_MAPPINGS.put(KerberosConstants.USE_KERBEROS_FOR_PASSWORD_AUTHENTICATION, "idm.test.kerberos.use.kerberos.for.password.authentication"); + + DEFAULT_VALUES.put(LDAPConstants.CONNECTION_URL, "ldap://localhost:10389"); + DEFAULT_VALUES.put(LDAPConstants.BASE_DN, "dc=keycloak,dc=org"); + DEFAULT_VALUES.put(LDAPConstants.USERS_DN, "ou=People,dc=keycloak,dc=org"); + DEFAULT_VALUES.put(LDAPConstants.BIND_DN, "uid=admin,ou=system"); + DEFAULT_VALUES.put(LDAPConstants.BIND_CREDENTIAL, "secret"); + DEFAULT_VALUES.put(LDAPConstants.VENDOR, LDAPConstants.VENDOR_OTHER); + DEFAULT_VALUES.put(LDAPConstants.CONNECTION_POOLING, "true"); + DEFAULT_VALUES.put(LDAPConstants.PAGINATION, "true"); + DEFAULT_VALUES.put(LDAPConstants.BATCH_SIZE_FOR_SYNC, String.valueOf(LDAPConstants.DEFAULT_BATCH_SIZE_FOR_SYNC)); + DEFAULT_VALUES.put(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, null); + DEFAULT_VALUES.put(LDAPConstants.USER_OBJECT_CLASSES, null); + DEFAULT_VALUES.put(LDAPConstants.EDIT_MODE, LDAPStorageProviderFactory.EditMode.READ_ONLY.toString()); + + DEFAULT_VALUES.put(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION, "false"); + DEFAULT_VALUES.put(KerberosConstants.KERBEROS_REALM, "KEYCLOAK.ORG"); + DEFAULT_VALUES.put(KerberosConstants.SERVER_PRINCIPAL, "HTTP/localhost@KEYCLOAK.ORG"); + URL keytabUrl = LDAPTestConfiguration.class.getResource("/kerberos/http.keytab"); + String keyTabPath = new File(keytabUrl.getFile()).getAbsolutePath(); + DEFAULT_VALUES.put(KerberosConstants.KEYTAB, keyTabPath); + DEFAULT_VALUES.put(KerberosConstants.DEBUG, "true"); + DEFAULT_VALUES.put(KerberosConstants.ALLOW_PASSWORD_AUTHENTICATION, "true"); + DEFAULT_VALUES.put(KerberosConstants.UPDATE_PROFILE_FIRST_LOGIN, "true"); + DEFAULT_VALUES.put(KerberosConstants.USE_KERBEROS_FOR_PASSWORD_AUTHENTICATION, "false"); + } + + public static LDAPTestConfiguration readConfiguration(String connectionPropertiesLocation) { + LDAPTestConfiguration ldapTestConfiguration = new LDAPTestConfiguration(); + ldapTestConfiguration.setConnectionPropertiesLocation(connectionPropertiesLocation); + ldapTestConfiguration.loadConnectionProperties(); + return ldapTestConfiguration; + } + + protected void loadConnectionProperties() { + Properties p = new Properties(); + try { + log.info("Reading LDAP configuration from: " + connectionPropertiesLocation); + InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(connectionPropertiesLocation); + p.load(is); + } + catch (Exception e) { + throw new RuntimeException(e); + } + + config = new HashMap(); + for (Map.Entry property : PROP_MAPPINGS.entrySet()) { + String propertyName = property.getKey(); + String configName = property.getValue(); + + String value = (String) p.get(configName); + if (value == null) { + value = DEFAULT_VALUES.get(propertyName); + } + + config.put(propertyName, value); + } + + startEmbeddedLdapLerver = Boolean.parseBoolean(p.getProperty("idm.test.ldap.start.embedded.ldap.server", "true")); + sleepTime = Integer.parseInt(p.getProperty("idm.test.ldap.sleepTime", "1000")); + log.info("Start embedded server: " + startEmbeddedLdapLerver); + log.info("Read config: " + config); + } + + public Map getLDAPConfig() { + return config; + } + + public void setConnectionPropertiesLocation(String connectionPropertiesLocation) { + this.connectionPropertiesLocation = connectionPropertiesLocation; + } + + public boolean isStartEmbeddedLdapLerver() { + return startEmbeddedLdapLerver; + } + + public int getSleepTime() { + return sleepTime; + } + +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPTestUtils.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPTestUtils.java new file mode 100644 index 0000000000..f4bf1823c0 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPTestUtils.java @@ -0,0 +1,296 @@ +/* + * 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.testsuite.federation.storage.ldap; + +import org.junit.Assert; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProvider; +import org.keycloak.storage.ldap.LDAPStorageProviderFactory; +import org.keycloak.storage.ldap.LDAPUtils; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery; +import org.keycloak.storage.ldap.idm.store.ldap.LDAPIdentityStore; +import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.membership.LDAPGroupMapperMode; +import org.keycloak.storage.ldap.mappers.membership.group.GroupLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.membership.group.GroupLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.membership.group.GroupMapperConfig; +import org.keycloak.storage.ldap.mappers.membership.role.RoleLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.membership.role.RoleLDAPStorageMapperFactory; +import org.keycloak.storage.ldap.mappers.membership.role.RoleMapperConfig; +import org.keycloak.storage.user.SynchronizationResult; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public class LDAPTestUtils { + + public static UserModel addLocalUser(KeycloakSession session, RealmModel realm, String username, String email, String password) { + UserModel user = session.userStorage().addUser(realm, username); + user.setEmail(email); + user.setEnabled(true); + + UserCredentialModel creds = new UserCredentialModel(); + creds.setType(CredentialRepresentation.PASSWORD); + creds.setValue(password); + + session.userCredentialManager().updateCredential(realm, user, creds); + return user; + } + + public static LDAPObject addLDAPUser(LDAPStorageProvider ldapProvider, RealmModel realm, final String username, + final String firstName, final String lastName, final String email, final String street, final String... postalCode) { + UserModel helperUser = new UserModelDelegate(null) { + + @Override + public String getUsername() { + return username; + } + + @Override + public String getFirstName() { + return firstName; + } + + @Override + public String getLastName() { + return lastName; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public List getAttribute(String name) { + if ("postal_code".equals(name) && postalCode != null && postalCode.length > 0) { + return Arrays.asList(postalCode); + } else if ("street".equals(name) && street != null) { + return Collections.singletonList(street); + } else { + return Collections.emptyList(); + } + } + }; + return LDAPUtils.addUserToLDAP(ldapProvider, realm, helperUser); + } + + public static void updateLDAPPassword(LDAPStorageProvider ldapProvider, LDAPObject ldapUser, String password) { + ldapProvider.getLdapIdentityStore().updatePassword(ldapUser, password); + + // Enable MSAD user through userAccountControls + if (ldapProvider.getLdapIdentityStore().getConfig().isActiveDirectory()) { + ldapUser.setSingleAttribute(LDAPConstants.USER_ACCOUNT_CONTROL, "512"); + ldapProvider.getLdapIdentityStore().update(ldapUser); + } + } + + public static LDAPStorageProvider getLdapProvider(KeycloakSession keycloakSession, ComponentModel ldapFedModel) { + return (LDAPStorageProvider)keycloakSession.getProvider(UserStorageProvider.class, ldapFedModel); + } + + public static void assertUserImported(UserProvider userProvider, RealmModel realm, String username, String expectedFirstName, String expectedLastName, String expectedEmail, String expectedPostalCode) { + UserModel user = userProvider.getUserByUsername(username, realm); + Assert.assertNotNull(user); + Assert.assertEquals(expectedFirstName, user.getFirstName()); + Assert.assertEquals(expectedLastName, user.getLastName()); + Assert.assertEquals(expectedEmail, user.getEmail()); + Assert.assertEquals(expectedPostalCode, user.getFirstAttribute("postal_code")); + } + + + // CRUD model mappers + + public static void addZipCodeLDAPMapper(RealmModel realm, ComponentModel providerModel) { + addUserAttributeMapper(realm, providerModel, "zipCodeMapper", "postal_code", LDAPConstants.POSTAL_CODE); + } + + public static ComponentModel addUserAttributeMapper(RealmModel realm, ComponentModel providerModel, String mapperName, String userModelAttributeName, String ldapAttributeName) { + ComponentModel mapperModel = KeycloakModelUtils.createComponentModel(mapperName, providerModel.getId(), UserAttributeLDAPStorageMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, userModelAttributeName, + UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, ldapAttributeName, + UserAttributeLDAPStorageMapper.READ_ONLY, "false", + UserAttributeLDAPStorageMapper.ALWAYS_READ_VALUE_FROM_LDAP, "false", + UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "false"); + return realm.addComponentModel(mapperModel); + } + + public static void addOrUpdateRoleLDAPMappers(RealmModel realm, ComponentModel providerModel, LDAPGroupMapperMode mode) { + ComponentModel mapperModel = getComponentByName(realm, providerModel, "realmRolesMapper"); + if (mapperModel != null) { + mapperModel.getConfig().putSingle(RoleMapperConfig.MODE, mode.toString()); + realm.updateComponent(mapperModel); + } else { + String baseDn = providerModel.getConfig().getFirst(LDAPConstants.BASE_DN); + mapperModel = KeycloakModelUtils.createComponentModel("realmRolesMapper", providerModel.getId(), RoleLDAPStorageMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + RoleMapperConfig.ROLES_DN, "ou=RealmRoles," + baseDn, + RoleMapperConfig.USE_REALM_ROLES_MAPPING, "true", + RoleMapperConfig.MODE, mode.toString()); + realm.addComponentModel(mapperModel); + } + + mapperModel = getComponentByName(realm, providerModel, "financeRolesMapper"); + if (mapperModel != null) { + mapperModel.getConfig().putSingle(RoleMapperConfig.MODE, mode.toString()); + realm.updateComponent(mapperModel); + } else { + String baseDn = providerModel.getConfig().getFirst(LDAPConstants.BASE_DN); + mapperModel = KeycloakModelUtils.createComponentModel("financeRolesMapper", providerModel.getId(), RoleLDAPStorageMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + RoleMapperConfig.ROLES_DN, "ou=FinanceRoles," + baseDn, + RoleMapperConfig.USE_REALM_ROLES_MAPPING, "false", + RoleMapperConfig.CLIENT_ID, "finance", + RoleMapperConfig.MODE, mode.toString()); + realm.addComponentModel(mapperModel); + } + } + + public static ComponentModel getComponentByName(RealmModel realm, ComponentModel providerModel, String name) { + List components = realm.getComponents(providerModel.getId(), LDAPStorageMapper.class.getName()); + for (ComponentModel component : components) { + if (component.getName().equals(name)) { + return component; + } + } + return null; + } + + public static void addOrUpdateGroupMapper(RealmModel realm, ComponentModel providerModel, LDAPGroupMapperMode mode, String descriptionAttrName, String... otherConfigOptions) { + ComponentModel mapperModel = getComponentByName(realm, providerModel, "groupsMapper"); + if (mapperModel != null) { + mapperModel.getConfig().putSingle(GroupMapperConfig.MODE, mode.toString()); + updateGroupMapperConfigOptions(mapperModel, otherConfigOptions); + realm.updateComponent(mapperModel); + } else { + String baseDn = providerModel.getConfig().getFirst(LDAPConstants.BASE_DN); + mapperModel = KeycloakModelUtils.createComponentModel("groupsMapper", providerModel.getId(), GroupLDAPStorageMapperFactory.PROVIDER_ID, LDAPStorageMapper.class.getName(), + GroupMapperConfig.GROUPS_DN, "ou=Groups," + baseDn, + GroupMapperConfig.MAPPED_GROUP_ATTRIBUTES, descriptionAttrName, + GroupMapperConfig.PRESERVE_GROUP_INHERITANCE, "true", + GroupMapperConfig.MODE, mode.toString()); + updateGroupMapperConfigOptions(mapperModel, otherConfigOptions); + realm.addComponentModel(mapperModel); + } + } + + public static void updateGroupMapperConfigOptions(ComponentModel mapperModel, String... configOptions) { + for (int i=0 ; i allUsers = ldapQuery.getResultList(); + + for (LDAPObject ldapUser : allUsers) { + ldapStore.remove(ldapUser); + } + } + + public static void removeAllLDAPRoles(KeycloakSession session, RealmModel appRealm, ComponentModel ldapModel, String mapperName) { + ComponentModel mapperModel = getComponentByName(appRealm, ldapModel, mapperName); + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPQuery roleQuery = getRoleMapper(mapperModel, ldapProvider, appRealm).createRoleQuery(); + List ldapRoles = roleQuery.getResultList(); + for (LDAPObject ldapRole : ldapRoles) { + ldapProvider.getLdapIdentityStore().remove(ldapRole); + } + } + + public static void removeAllLDAPGroups(KeycloakSession session, RealmModel appRealm, ComponentModel ldapModel, String mapperName) { + ComponentModel mapperModel = getComponentByName(appRealm, ldapModel, mapperName); + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPQuery roleQuery = getGroupMapper(mapperModel, ldapProvider, appRealm).createGroupQuery(); + List ldapRoles = roleQuery.getResultList(); + for (LDAPObject ldapRole : ldapRoles) { + ldapProvider.getLdapIdentityStore().remove(ldapRole); + } + } + + public static void createLDAPRole(KeycloakSession session, RealmModel appRealm, ComponentModel ldapModel, String mapperName, String roleName) { + ComponentModel mapperModel = getComponentByName(appRealm, ldapModel, mapperName); + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + getRoleMapper(mapperModel, ldapProvider, appRealm).createLDAPRole(roleName); + } + + public static LDAPObject createLDAPGroup(KeycloakSession session, RealmModel appRealm, ComponentModel ldapModel, String groupName, String... additionalAttrs) { + ComponentModel mapperModel = getComponentByName(appRealm, ldapModel, "groupsMapper"); + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + + Map> additAttrs = new HashMap<>(); + for (int i=0 ; i {{:: 'credential-reset-actions' | translate}}