From 3c3f003a3817bbbb90de3ae5df0f2c6ac92f42ea Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Wed, 2 Feb 2022 10:30:47 +0100 Subject: [PATCH] LDAP Map storage support to support read/write for roles Closes #9929 --- dependencies/server-all/pom.xml | 4 + model/map-ldap/pom.xml | 42 ++ .../ldap/LdapMapKeycloakTransaction.java | 92 +++ .../storage/ldap/LdapMapStorageProvider.java | 52 ++ .../ldap/LdapMapStorageProviderFactory.java | 95 +++ .../ldap/LdapModelCriteriaBuilder.java | 121 ++++ ...lCriteriaBuilderAssumingEqualForField.java | 61 ++ .../LdapMapCommonGroupMapperConfig.java | 87 +++ .../storage/ldap/config/LdapMapConfig.java | 302 +++++++++ .../map/storage/ldap/model/LdapMapDn.java | 297 +++++++++ .../map/storage/ldap/model/LdapMapObject.java | 208 ++++++ .../map/storage/ldap/model/LdapMapQuery.java | 139 ++++ .../role/LdapRoleMapKeycloakTransaction.java | 410 ++++++++++++ .../role/LdapRoleModelCriteriaBuilder.java | 225 +++++++ .../role/config/LdapMapRoleMapperConfig.java | 157 +++++ .../LdapMapRoleEntityFieldDelegate.java | 46 ++ .../ldap/role/entity/LdapRoleEntity.java | 335 ++++++++++ .../ldap/store/LdapMapContextManager.java | 273 ++++++++ .../ldap/store/LdapMapEscapeStrategy.java | 111 ++++ .../ldap/store/LdapMapIdentityStore.java | 533 +++++++++++++++ .../ldap/store/LdapMapOctetStringEncoder.java | 56 ++ .../ldap/store/LdapMapOperationDecorator.java | 30 + .../ldap/store/LdapMapOperationManager.java | 629 ++++++++++++++++++ .../map/storage/ldap/store/LdapMapUtil.java | 254 +++++++ ...dels.map.storage.MapStorageProviderFactory | 18 + .../models/map/storage/ldap/Config.java | 62 ++ .../config/LdapMapRoleMapperConfigTest.java | 38 ++ .../ldap/store/LdapMapEscapeStrategyTest.java | 40 ++ .../models/map/common/DeepCloner.java | 6 +- .../models/map/role/MapRoleProvider.java | 7 +- .../map/storage/MapKeycloakTransaction.java | 10 +- .../storage/chm/MapModelCriteriaBuilder.java | 30 +- model/pom.xml | 1 + pom.xml | 5 + testsuite/model/pom.xml | 13 + .../model/parameters/LdapMapStorage.java | 109 +++ testsuite/utils/pom.xml | 1 - .../resources/META-INF/keycloak-server.json | 27 + 38 files changed, 4904 insertions(+), 22 deletions(-) create mode 100644 model/map-ldap/pom.xml create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapKeycloakTransaction.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProvider.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProviderFactory.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapModelCriteriaBuilder.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/MapModelCriteriaBuilderAssumingEqualForField.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapCommonGroupMapperConfig.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapConfig.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapDn.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapObject.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapQuery.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleMapKeycloakTransaction.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleModelCriteriaBuilder.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/config/LdapMapRoleMapperConfig.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapMapRoleEntityFieldDelegate.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapRoleEntity.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapContextManager.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapEscapeStrategy.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapIdentityStore.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOctetStringEncoder.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationDecorator.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationManager.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapUtil.java create mode 100644 model/map-ldap/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProviderFactory create mode 100644 model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/Config.java create mode 100644 model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/role/config/LdapMapRoleMapperConfigTest.java create mode 100644 model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/store/LdapMapEscapeStrategyTest.java create mode 100644 testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/LdapMapStorage.java diff --git a/dependencies/server-all/pom.xml b/dependencies/server-all/pom.xml index 1ef2c8c3d6..28db9d99f1 100755 --- a/dependencies/server-all/pom.xml +++ b/dependencies/server-all/pom.xml @@ -48,6 +48,10 @@ org.keycloak keycloak-model-map-jpa + + org.keycloak + keycloak-model-map-ldap + org.keycloak keycloak-model-infinispan diff --git a/model/map-ldap/pom.xml b/model/map-ldap/pom.xml new file mode 100644 index 0000000000..67afbf7da0 --- /dev/null +++ b/model/map-ldap/pom.xml @@ -0,0 +1,42 @@ + + + + + + keycloak-model-pom + org.keycloak + 18.0.0-SNAPSHOT + + 4.0.0 + + keycloak-model-map-ldap + Keycloak Model Map LDAP + + + + org.keycloak + keycloak-model-map + + + junit + junit + test + + + diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapKeycloakTransaction.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapKeycloakTransaction.java new file mode 100644 index 0000000000..e399393395 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapKeycloakTransaction.java @@ -0,0 +1,92 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.ldap; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.map.common.UpdatableEntity; +import org.keycloak.models.map.storage.MapKeycloakTransaction; +import org.keycloak.models.map.storage.QueryParameters; + +public abstract class LdapMapKeycloakTransaction implements MapKeycloakTransaction { + + private boolean active; + private boolean rollback; + + public LdapMapKeycloakTransaction() { + } + + protected abstract static class MapTaskWithValue { + public abstract void execute(); + } + + protected abstract static class DeleteOperation extends MapTaskWithValue { + } + + protected final LinkedList tasksOnRollback = new LinkedList<>(); + + protected final LinkedList tasksOnCommit = new LinkedList<>(); + + protected final Map entities = new HashMap<>(); + + public long getCount(QueryParameters queryParameters) { + return read(queryParameters).count(); + } + + public long delete(QueryParameters queryParameters) { + return read(queryParameters).map(m -> delete(m.getId()) ? 1 : 0).collect(Collectors.summarizingLong(val -> val)).getSum(); + } + + @Override + public void begin() { + active = true; + } + + @Override + public void commit() { + if (rollback) { + throw new RuntimeException("Rollback only!"); + } + } + + @Override + public void rollback() { + } + + @Override + public void setRollbackOnly() { + rollback = true; + } + + @Override + public boolean getRollbackOnly() { + return rollback; + } + + @Override + public boolean isActive() { + return active; + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProvider.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProvider.java new file mode 100644 index 0000000000..6db8624b60 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProvider.java @@ -0,0 +1,52 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.ldap; + +import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.map.storage.MapKeycloakTransaction; +import org.keycloak.models.map.storage.MapStorage; +import org.keycloak.models.map.storage.MapStorageProvider; +import org.keycloak.models.map.storage.MapStorageProviderFactory.Flag; + +public class LdapMapStorageProvider implements MapStorageProvider { + + private final LdapMapStorageProviderFactory factory; + private final String sessionTxPrefix; + + public LdapMapStorageProvider(LdapMapStorageProviderFactory factory, String sessionTxPrefix) { + this.factory = factory; + this.sessionTxPrefix = sessionTxPrefix; + } + + @Override + public void close() { + } + + @Override + @SuppressWarnings("unchecked") + public MapStorage getStorage(Class modelType, Flag... flags) { + return session -> { + MapKeycloakTransaction sessionTx = session.getAttribute(sessionTxPrefix + modelType.hashCode(), MapKeycloakTransaction.class); + if (sessionTx == null) { + sessionTx = factory.createTransaction(session, modelType); + session.setAttribute(sessionTxPrefix + modelType.hashCode(), sessionTx); + } + return sessionTx; + }; + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProviderFactory.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProviderFactory.java new file mode 100644 index 0000000000..2e35accbb3 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProviderFactory.java @@ -0,0 +1,95 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.ldap; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.component.AmphibianProviderFactory; +import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RoleModel; +import org.keycloak.models.map.storage.MapKeycloakTransaction; +import org.keycloak.models.map.storage.MapStorageProvider; +import org.keycloak.models.map.storage.MapStorageProviderFactory; +import org.keycloak.models.map.storage.ldap.role.LdapRoleMapKeycloakTransaction; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +public class LdapMapStorageProviderFactory implements + AmphibianProviderFactory, + MapStorageProviderFactory, + EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "ldap-map-storage"; + private static final AtomicInteger SESSION_TX_PREFIX_ENUMERATOR = new AtomicInteger(0); + private static final String SESSION_TX_PREFIX = "ldap-map-tx-"; + private final String sessionTxPrefixForFactoryInstance; + + private Config.Scope config; + + @SuppressWarnings("rawtypes") + private static final Map, LdapRoleMapKeycloakTransaction.LdapRoleMapKeycloakTransactionFunction> MODEL_TO_TX = new HashMap<>(); + static { + MODEL_TO_TX.put(RoleModel.class, LdapRoleMapKeycloakTransaction::new); + } + + public LdapMapStorageProviderFactory() { + sessionTxPrefixForFactoryInstance = SESSION_TX_PREFIX + SESSION_TX_PREFIX_ENUMERATOR.getAndIncrement() + "-"; + } + + public MapKeycloakTransaction createTransaction(KeycloakSession session, Class modelType) { + return MODEL_TO_TX.get(modelType).apply(session, config); + } + + @Override + public MapStorageProvider create(KeycloakSession session) { + return new LdapMapStorageProvider(this, sessionTxPrefixForFactoryInstance); + } + + @Override + public void init(Config.Scope config) { + this.config = config; + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "LDAP Map Storage"; + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.MAP_STORAGE); + } + + @Override + public void close() { + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapModelCriteriaBuilder.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapModelCriteriaBuilder.java new file mode 100644 index 0000000000..3750d393c2 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapModelCriteriaBuilder.java @@ -0,0 +1,121 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.ldap; + +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.map.storage.ModelCriteriaBuilder; +import org.keycloak.models.map.storage.ldap.store.LdapMapUtil; +import org.keycloak.models.map.storage.ldap.store.LdapMapEscapeStrategy; +import org.keycloak.models.map.storage.ldap.store.LdapMapOctetStringEncoder; + +import java.util.Date; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Abstract class containing methods common to all Ldap*ModelCriteriaBuilder implementations + * + * @param Entity + * @param Model + * @param specific implementation of this class + */ +public abstract class LdapModelCriteriaBuilder> implements ModelCriteriaBuilder { + + private final Function, Self> instantiator; + private Supplier predicateFunc = null; + + public LdapModelCriteriaBuilder(Function, Self> instantiator) { + this.instantiator = instantiator; + } + + @SuppressWarnings("unchecked") + @Override + public Self and(Self... builders) { + return instantiator.apply(() -> { + StringBuilder filter = new StringBuilder(); + for (Self builder : builders) { + filter.append(builder.getPredicateFunc().get()); + } + if (filter.length() > 0) { + filter.insert(0, "(&"); + filter.append(")"); + } + return filter; + }); + } + + @SuppressWarnings("unchecked") + @Override + public Self or(Self... builders) { + return instantiator.apply(() -> { + StringBuilder filter = new StringBuilder(); + filter.append("(|"); + for (Self builder : builders) { + filter.append(builder.getPredicateFunc().get()); + } + filter.append(")"); + return filter; + }); + } + + @Override + public Self not(Self builder) { + return instantiator.apply(() -> { + StringBuilder filter = new StringBuilder(); + filter.append("(!"); + filter.append(builder.getPredicateFunc().get()); + filter.append(")"); + return filter; + }); + } + + public Supplier getPredicateFunc() { + return predicateFunc; + } + + public LdapModelCriteriaBuilder(Function, Self> instantiator, + Supplier predicateFunc) { + this.instantiator = instantiator; + this.predicateFunc = predicateFunc; + } + + protected StringBuilder equal(String field, Object value, LdapMapEscapeStrategy ldapMapEscapeStrategy, boolean isBinary) { + Object parameterValue = value; + if (value instanceof Date) { + parameterValue = LdapMapUtil.formatDate((Date) parameterValue); + } + + String escaped = new LdapMapOctetStringEncoder(ldapMapEscapeStrategy).encode(parameterValue, isBinary); + + return new StringBuilder().append("(").append(field).append(LDAPConstants.EQUAL).append(escaped).append(")"); + } + + protected StringBuilder in(String name, Object[] valuesToCompare, boolean isBinary) { + StringBuilder filter = new StringBuilder(); + filter.append("(|("); + + for (Object o : valuesToCompare) { + Object value = new LdapMapOctetStringEncoder().encode(o, false); + + filter.append("(").append(name).append(LDAPConstants.EQUAL).append(value).append(")"); + } + + filter.append("))"); + return filter; + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/MapModelCriteriaBuilderAssumingEqualForField.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/MapModelCriteriaBuilderAssumingEqualForField.java new file mode 100644 index 0000000000..bd73eda728 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/MapModelCriteriaBuilderAssumingEqualForField.java @@ -0,0 +1,61 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap; + +import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.map.common.StringKeyConvertor; +import org.keycloak.models.map.storage.chm.MapModelCriteriaBuilder; +import org.keycloak.storage.SearchableModelField; + +import java.util.Map; +import java.util.function.Predicate; + +public class MapModelCriteriaBuilderAssumingEqualForField extends MapModelCriteriaBuilder { + + private final Map, UpdatePredicatesFunc> fieldPredicates; + private final StringKeyConvertor keyConvertor; + private final SearchableModelField modelFieldThatShouldCompareToTrueForEqual; + + public MapModelCriteriaBuilderAssumingEqualForField(StringKeyConvertor keyConvertor, Map, UpdatePredicatesFunc> fieldPredicates, SearchableModelField modelFieldThatShouldCompareToTrueForEqual) { + this(keyConvertor, fieldPredicates, ALWAYS_TRUE, ALWAYS_TRUE, modelFieldThatShouldCompareToTrueForEqual); + } + + protected MapModelCriteriaBuilderAssumingEqualForField(StringKeyConvertor keyConvertor, Map, UpdatePredicatesFunc> fieldPredicates, Predicate indexReadFilter, Predicate sequentialReadFilter, SearchableModelField modelFieldThatShouldCompareToTrueForEqual) { + super(keyConvertor, fieldPredicates, indexReadFilter, sequentialReadFilter); + this.keyConvertor = keyConvertor; + this.modelFieldThatShouldCompareToTrueForEqual = modelFieldThatShouldCompareToTrueForEqual; + this.fieldPredicates = fieldPredicates; + } + + @Override + public MapModelCriteriaBuilder compare(SearchableModelField modelField, Operator op, Object... values) { + if (modelField == modelFieldThatShouldCompareToTrueForEqual && op == Operator.EQ) { + return instantiateNewInstance( + keyConvertor, + fieldPredicates, + ALWAYS_TRUE, + ALWAYS_TRUE); + } + return super.compare(modelField, op, values); + } + + @Override + protected MapModelCriteriaBuilder instantiateNewInstance(StringKeyConvertor keyConvertor, Map, UpdatePredicatesFunc> fieldPredicates, Predicate indexReadFilter, Predicate sequentialReadFilter) { + return new MapModelCriteriaBuilderAssumingEqualForField<>(keyConvertor, fieldPredicates, indexReadFilter, sequentialReadFilter, modelFieldThatShouldCompareToTrueForEqual); + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapCommonGroupMapperConfig.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapCommonGroupMapperConfig.java new file mode 100644 index 0000000000..2b96a37c3a --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapCommonGroupMapperConfig.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.models.map.storage.ldap.config; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; + +import java.util.HashSet; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public abstract class LdapMapCommonGroupMapperConfig { + + // 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"; + + // Used just for membershipType=UID. Name of LDAP attribute on user, which is used for membership mappings. Usually it will be "uid" + public static final String MEMBERSHIP_USER_LDAP_ATTRIBUTE = "membership.user.ldap.attribute"; + + // See docs for Mode enum + public static final String MODE = "mode"; + + // See docs for UserRolesRetrieveStrategy enum + public static final String USER_ROLES_RETRIEVE_STRATEGY = "user.roles.retrieve.strategy"; + + // Used just for UserRolesRetrieveStrategy.GetRolesFromUserMemberOfAttribute. It's the name of the attribute on LDAP user, which is used to track the groups which user is member. + // Usually it will "memberof" + public static final String MEMBEROF_LDAP_ATTRIBUTE = "memberof.ldap.attribute"; + + + protected final ComponentModel mapperModel; + + public LdapMapCommonGroupMapperConfig(ComponentModel mapperModel) { + this.mapperModel = mapperModel; + } + + public String getMembershipLdapAttribute() { + String membershipAttrName = mapperModel.getConfig().getFirst(MEMBERSHIP_LDAP_ATTRIBUTE); + return membershipAttrName!=null ? membershipAttrName : LDAPConstants.MEMBER; + } + + public String getMembershipUserLdapAttribute(LdapMapConfig ldapMapConfig) { + String membershipUserAttrName = mapperModel.getConfig().getFirst(MEMBERSHIP_USER_LDAP_ATTRIBUTE); + return membershipUserAttrName!=null ? membershipUserAttrName : ldapMapConfig.getUsernameLdapAttribute(); + } + + public String getMemberOfLdapAttribute() { + String memberOfLdapAttrName = mapperModel.getConfig().getFirst(MEMBEROF_LDAP_ATTRIBUTE); + return memberOfLdapAttrName!=null ? memberOfLdapAttrName : LDAPConstants.MEMBER_OF; + } + + 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 getLDAPGroupNameLdapAttribute(); + + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapConfig.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapConfig.java new file mode 100644 index 0000000000..ad5f0248aa --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapConfig.java @@ -0,0 +1,302 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.config; + +import org.keycloak.Config; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.map.storage.ldap.role.config.LdapMapRoleMapperConfig; +import org.keycloak.storage.UserStorageProvider; + +import javax.naming.directory.SearchControls; +import java.util.Collection; +import java.util.HashSet; +import java.util.Properties; +import java.util.Set; + +public class LdapMapConfig { + private final MultivaluedHashMap config; + + public LdapMapConfig(Config.Scope config) { + this.config = hm(config); + } + + private static MultivaluedHashMap hm(Config.Scope config) { + return new MultivaluedHashMap() { + @Override + public String getFirst(String key) { + return config.get(key); + } + }; + } + + // from: RoleMapperConfig + public Collection getRoleObjectClasses() { + String objectClasses = config.getFirst(LdapMapRoleMapperConfig.ROLE_OBJECT_CLASSES); + if (objectClasses == null) { + // For Active directory, the default is 'group' . For other servers 'groupOfNames' + objectClasses = isActiveDirectory() ? LDAPConstants.GROUP : LDAPConstants.GROUP_OF_NAMES; + } + + return getConfigValues(objectClasses); + } + + // from: RoleMapperConfig + 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; + } + + private final Set binaryAttributeNames = new HashSet<>(); + + 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 boolean useExtendedPasswordModifyOp() { + String value = config.getFirst(LDAPConstants.USE_PASSWORD_MODIFY_EXTENDED_OP); + return Boolean.parseBoolean(value); + } + + public String getUseTruststoreSpi() { + return config.getFirst(LDAPConstants.USE_TRUSTSTORE_SPI); + } + + 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 (String objectClass : objectClasses) { + userObjClasses.add(objectClass.trim()); + } + return userObjClasses; + } + + public String getBindDN() { + return config.getFirst(LDAPConstants.BIND_DN); + } + + public String getBindCredential() { + return config.getFirst(LDAPConstants.BIND_CREDENTIAL); + } + + public String getVendor() { + return config.getFirst(LDAPConstants.VENDOR); + } + + public boolean isActiveDirectory() { + String vendor = getVendor(); + return vendor != null && vendor.equals(LDAPConstants.VENDOR_ACTIVE_DIRECTORY); + } + + public boolean isValidatePasswordPolicy() { + String validatePPolicy = config.getFirst(LDAPConstants.VALIDATE_PASSWORD_POLICY); + return Boolean.parseBoolean(validatePPolicy); + } + + public boolean isTrustEmail(){ + String trustEmail = config.getFirst(LDAPConstants.TRUST_EMAIL); + return Boolean.parseBoolean(trustEmail); + } + + public String getConnectionPooling() { + if(isStartTls()) { + return null; + } else { + return config.getFirst(LDAPConstants.CONNECTION_POOLING); + } + } + + public String getConnectionPoolingAuthentication() { + return config.getFirst(LDAPConstants.CONNECTION_POOLING_AUTHENTICATION); + } + + public String getConnectionPoolingDebug() { + return config.getFirst(LDAPConstants.CONNECTION_POOLING_DEBUG); + } + + public String getConnectionPoolingInitSize() { + return config.getFirst(LDAPConstants.CONNECTION_POOLING_INITSIZE); + } + + public String getConnectionPoolingMaxSize() { + return config.getFirst(LDAPConstants.CONNECTION_POOLING_MAXSIZE); + } + + public String getConnectionPoolingPrefSize() { + return config.getFirst(LDAPConstants.CONNECTION_POOLING_PREFSIZE); + } + + public String getConnectionPoolingProtocol() { + return config.getFirst(LDAPConstants.CONNECTION_POOLING_PROTOCOL); + } + + public String getConnectionPoolingTimeout() { + return config.getFirst(LDAPConstants.CONNECTION_POOLING_TIMEOUT); + } + + public String getConnectionTimeout() { + return config.getFirst(LDAPConstants.CONNECTION_TIMEOUT); + } + + public String getReadTimeout() { + return config.getFirst(LDAPConstants.READ_TIMEOUT); + } + + public Properties getAdditionalConnectionProperties() { + // not supported for now + return null; + } + + public int getSearchScope() { + String searchScope = config.getFirst(LDAPConstants.SEARCH_SCOPE); + return searchScope == null ? SearchControls.SUBTREE_SCOPE : Integer.parseInt(searchScope); + } + + public String getUuidLDAPAttributeName() { + String uuidAttrName = config.getFirst(LDAPConstants.UUID_LDAP_ATTRIBUTE); + if (uuidAttrName == null) { + // Differences of unique attribute among various vendors + String vendor = getVendor(); + uuidAttrName = LDAPConstants.getUuidAttributeName(vendor); + } + + return uuidAttrName; + } + + public boolean isObjectGUID() { + return getUuidLDAPAttributeName().equalsIgnoreCase(LDAPConstants.OBJECT_GUID); + } + + public boolean isEdirectoryGUID() { + return isEdirectory() && getUuidLDAPAttributeName().equalsIgnoreCase(LDAPConstants.NOVELL_EDIRECTORY_GUID); + } + + public boolean isPagination() { + String pagination = config.getFirst(LDAPConstants.PAGINATION); + return Boolean.parseBoolean(pagination); + } + + public int getBatchSizeForSync() { + String pageSizeConfig = config.getFirst(LDAPConstants.BATCH_SIZE_FOR_SYNC); + return pageSizeConfig!=null ? Integer.parseInt(pageSizeConfig) : LDAPConstants.DEFAULT_BATCH_SIZE_FOR_SYNC; + } + + public String getUsernameLdapAttribute() { + String username = config.getFirst(LDAPConstants.USERNAME_LDAP_ATTRIBUTE); + if (username == null) { + username = isActiveDirectory() ? LDAPConstants.CN : LDAPConstants.UID; + } + return username; + } + + public String getRdnLdapAttribute() { + String rdn = config.getFirst(LDAPConstants.RDN_LDAP_ATTRIBUTE); + if (rdn == null) { + rdn = getUsernameLdapAttribute(); + + if (rdn.equalsIgnoreCase(LDAPConstants.SAM_ACCOUNT_NAME)) { + // Just for the backwards compatibility 1.2 -> 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 boolean isStartTls() { + return Boolean.parseBoolean(config.getFirst(LDAPConstants.START_TLS)); + } + + public UserStorageProvider.EditMode getEditMode() { + String editModeString = config.getFirst(LDAPConstants.EDIT_MODE); + if (editModeString == null) { + return UserStorageProvider.EditMode.READ_ONLY; + } else { + return UserStorageProvider.EditMode.valueOf(editModeString); + } + } + + public void addBinaryAttribute(String attrName) { + binaryAttributeNames.add(attrName); + } + + public Set getBinaryAttributeNames() { + return binaryAttributeNames; + } + + + public boolean isEdirectory() { + return LDAPConstants.VENDOR_NOVELL_EDIRECTORY.equalsIgnoreCase(getVendor()); + } + + @Override + public String toString() { + MultivaluedHashMap copy = new MultivaluedHashMap<>(config); + copy.remove(LDAPConstants.BIND_CREDENTIAL); + return copy + ", binaryAttributes: " + binaryAttributeNames; + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapDn.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapDn.java new file mode 100644 index 0000000000..d5cfe0f3d3 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapDn.java @@ -0,0 +1,297 @@ +/* + * 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.models.map.storage.ldap.model; + +import javax.naming.ldap.Rdn; +import java.util.Collection; +import java.util.Deque; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * @author Marek Posolda + */ +public class LdapMapDn { + + private static final Pattern DN_PATTERN = Pattern.compile("(? entries; + + private LdapMapDn() { + this.entries = new LinkedList<>(); + } + + private LdapMapDn(Deque entries) { + this.entries = entries; + } + + public static LdapMapDn fromString(String dnString) { + LdapMapDn dn = new LdapMapDn(); + + // 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 = DN_PATTERN.split(dnString); + for (String entryStr : rdns) { + if (entryStr.indexOf('+') == -1) { + // This is 99.9% of cases where RDN consists of single key-value pair + SubEntry subEntry = parseSingleSubEntry(dn, entryStr); + dn.addLast(new RDN(subEntry)); + } else { + // This is 0.1% of cases where RDN consists of more key-value pairs like "uid=foo+cn=bar" + String[] subEntries = ENTRY_PATTERN.split(entryStr); + RDN entry = new RDN(); + for (String subEntryStr : subEntries) { + SubEntry subEntry = parseSingleSubEntry(dn, subEntryStr); + entry.addSubEntry(subEntry); + } + dn.addLast(entry); + } + } + + return dn; + } + + // parse single sub-entry and add it to the "dn" . Assumption is that subentry is something like "uid=bar" and does not contain + character + private static SubEntry parseSingleSubEntry(LdapMapDn dn, String subEntryStr) { + String[] rdn = SUB_ENTRY_PATTERN.split(subEntryStr); + if (rdn.length >1) { + return new SubEntry(rdn[0].trim(), rdn[1].trim()); + } else { + return new SubEntry(rdn[0].trim(), ""); + } + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof LdapMapDn)) { + return false; + } + + return toString().equals(obj.toString()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + @Override + public String toString() { + return toString(entries); + } + + private static String toString(Collection entries) { + StringBuilder builder = new StringBuilder(); + + boolean first = true; + for (RDN rdn : entries) { + if (first) { + first = false; + } else { + builder.append(","); + } + builder.append(rdn.toString()); + } + + return builder.toString(); + } + + /** + * @return first entry. Usually entry corresponding to something like "uid=joe" from the DN like "uid=joe,dc=something,dc=org" + */ + public RDN getFirstRdn() { + return entries.getFirst(); + } + + private static String unescapeValue(String escaped) { + // Something needed to handle non-String types? + return Rdn.unescapeValue(escaped).toString(); + } + + private static String escapeValue(String unescaped) { + // Something needed to handle non-String types? + return Rdn.escapeValue(unescaped); + } + + /** + * + * @return DN like "dc=something,dc=org" from the DN like "uid=joe,dc=something,dc=org". + * Returned DN will be new clone not related to the original DN instance. + * + */ + public LdapMapDn getParentDn() { + LinkedList parentDnEntries = new LinkedList<>(entries); + parentDnEntries.remove(); + return new LdapMapDn(parentDnEntries); + } + + public boolean isDescendantOf(LdapMapDn 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 = escapeValue(rdnValue); + entries.addFirst(new RDN(new SubEntry(rdnName, rdnValue))); + } + + public void addFirst(RDN entry) { + entries.addFirst(entry); + } + + private void addLast(RDN entry) { + entries.addLast(entry); + } + + /** + * Single RDN inside the DN. RDN usually consists of single item like "uid=john" . In some rare cases, it can have multiple + * sub-entries like "uid=john+sn=Doe" + */ + public static class RDN { + + private final List subs = new LinkedList<>(); + + private RDN() { + } + + private RDN(SubEntry subEntry) { + subs.add(subEntry); + } + + private void addSubEntry(SubEntry subEntry) { + subs.add(subEntry); + } + + /** + * @return Keys in the RDN. Returned list is the copy, which is not linked to the original RDN + */ + public List getAllKeys() { + return subs.stream().map(SubEntry::getAttrName).collect(Collectors.toList()); + } + + /** + * Assume that RDN is something like "uid=john", then this method will return "john" in case that attrName is "uid" . + * This is useful in case that RDN is multi-key - something like "uid=john+cn=John Doe" and we want to return just "john" as the value of "uid" + * + * The returned value will be unescaped + * + */ + public String getAttrValue(String attrName) { + for (SubEntry sub : subs) { + if (attrName.equalsIgnoreCase(sub.attrName)) { + return LdapMapDn.unescapeValue(sub.attrValue); + } + } + return null; + } + + public void setAttrValue(String attrName, String newAttrValue) { + for (SubEntry sub : subs) { + if (attrName.equalsIgnoreCase(sub.attrName)) { + sub.attrValue = escapeValue(newAttrValue); + return; + } + } + addSubEntry(new SubEntry(attrName, escapeValue(newAttrValue))); + } + + public boolean removeAttrValue(String attrName) { + SubEntry toRemove = null; + for (SubEntry sub : subs) { + if (attrName.equalsIgnoreCase(sub.attrName)) { + toRemove = sub; + } + } + + if (toRemove != null) { + subs.remove(toRemove); + return true; + } else { + return false; + } + } + + @Override + public String toString() { + return toString(true); + } + + /** + * + * @param escaped indicates whether return escaped or unescaped values. EG. "uid=john,comma" VS "uid=john\,comma" + */ + public String toString(boolean escaped) { + StringBuilder builder = new StringBuilder(); + + boolean first = true; + for (SubEntry subEntry : subs) { + if (first) { + first = false; + } else { + builder.append('+'); + } + builder.append(subEntry.toString(escaped)); + } + + return builder.toString(); + } + } + + private static class SubEntry { + private final String attrName; + private String attrValue; + + private SubEntry(String attrName, String attrValue) { + this.attrName = attrName; + this.attrValue = attrValue; + } + + private String getAttrName() { + return attrName; + } + + @Override + public String toString() { + return toString(true); + } + + private String toString(boolean escaped) { + String val = escaped ? attrValue : unescapeValue(attrValue); + return attrName + '=' + val; + } + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapObject.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapObject.java new file mode 100644 index 0000000000..54256f0d57 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapObject.java @@ -0,0 +1,208 @@ +/* + * 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.models.map.storage.ldap.model; + +import org.jboss.logging.Logger; +import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.map.common.EntityField; +import org.keycloak.models.map.common.delegate.EntityFieldDelegate; + +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 LdapMapObject implements AbstractEntity { + + private static final Logger logger = Logger.getLogger(LdapMapObject.class); + + private String id; + private LdapMapDn dn; + + // In most cases, there is single "rdnAttributeName" . Usually "uid" or "cn" + private final List rdnAttributeNames = new LinkedList<>(); + + 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<>(); + + // range attributes are always read from 0 to max so just saving the top value + private final Map rangedAttributes = new HashMap<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public LdapMapDn getDn() { + return dn; + } + + public void setDn(LdapMapDn 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 List getRdnAttributeNames() { + return rdnAttributeNames; + } + + /** + * Useful when single value will be used as the "RDN" attribute. Which will be most of the cases + */ + public void setRdnAttributeName(String rdnAttributeName) { + this.rdnAttributeNames.clear(); + this.rdnAttributeNames.add(rdnAttributeName); + } + + public void setRdnAttributeNames(List rdnAttributeNames) { + this.rdnAttributeNames.clear(); + this.rdnAttributeNames.addAll(rdnAttributeNames); + } + + public void addRdnAttributeName(String rdnAttributeName) { + this.rdnAttributeNames.add(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 boolean isRangeComplete(String name) { + return !rangedAttributes.containsKey(name); + } + + public int getCurrentRange(String name) { + return rangedAttributes.get(name); + } + + public boolean isRangeCompleteForAllAttributes() { + return rangedAttributes.isEmpty(); + } + + public void addRangedAttribute(String name, int max) { + Integer current = rangedAttributes.get(name); + if (current == null || max > current) { + rangedAttributes.put(name, max); + } + } + + public void populateRangedAttribute(LdapMapObject obj, String name) { + Set newValues = obj.getAttributes().get(name); + if (newValues != null && attributes.containsKey(name)) { + attributes.get(name).addAll(newValues); + if (!obj.isRangeComplete(name)) { + addRangedAttribute(name, obj.getCurrentRange(name)); + } else { + rangedAttributes.remove(name); + } + } + } + + public Map> getAttributes() { + return attributes; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + + if (!getClass().isInstance(obj)) { + return false; + } + + LdapMapObject other = (LdapMapObject) obj; + + return getId() != null && other.getId() != null && getId().equals(other.getId()); + } + + @Override + public int hashCode() { + int result = getId() != null ? getId().hashCode() : 0; + result = 31 * result + (getId() != null ? getId().hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "LdapMapObject [ dn: " + dn + " , id: " + id + ", attributes: " + attributes + + ", readOnly attribute names: " + readOnlyAttributeNames + ", ranges: " + rangedAttributes + " ]"; + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapQuery.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapQuery.java new file mode 100644 index 0000000000..e29c6e48d4 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/model/LdapMapQuery.java @@ -0,0 +1,139 @@ +/* + * 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.models.map.storage.ldap.model; + +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.map.storage.ldap.LdapModelCriteriaBuilder; + +import javax.naming.directory.SearchControls; +import java.util.ArrayList; +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. + * + * LDAPQuery should be closed after use in case that pagination was used (initPagination was called) + * Closing LDAPQuery is very important in case ldapContextManager contains VaultSecret + * + * @author Shane Bryzak + */ +public class LdapMapQuery implements AutoCloseable { + + private static final Logger logger = Logger.getLogger(LdapMapQuery.class); + + private int offset; + private int limit; + private String searchDn; + private LdapModelCriteriaBuilder modelCriteriaBuilder; + + 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 void setSearchDn(String searchDn) { + this.searchDn = searchDn; + } + + public void addObjectClasses(Collection objectClasses) { + this.objectClasses.addAll(objectClasses); + } + + public void addReturningLdapAttribute(String ldapAttributeName) { + this.returningLdapAttributes.add(ldapAttributeName); + } + + public void addReturningReadOnlyLdapAttribute(String ldapAttributeName) { + this.returningReadOnlyLdapAttributes.add(ldapAttributeName.toLowerCase()); + } + + public LdapMapQuery addMappers(Collection mappers) { + this.mappers.addAll(mappers); + return this; + } + + public void setSearchScope(int searchScope) { + this.searchScope = searchScope; + } + + 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 LdapMapQuery setOffset(int offset) { + this.offset = offset; + return this; + } + + public LdapMapQuery setLimit(int limit) { + this.limit = limit; + return this; + } + + @Override + public void close() { + } + + public void setModelCriteriaBuilder(LdapModelCriteriaBuilder ldapModelCriteriaBuilder) { + this.modelCriteriaBuilder = ldapModelCriteriaBuilder; + } + + public LdapModelCriteriaBuilder getModelCriteriaBuilder() { + return modelCriteriaBuilder; + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleMapKeycloakTransaction.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleMapKeycloakTransaction.java new file mode 100644 index 0000000000..af31769918 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleMapKeycloakTransaction.java @@ -0,0 +1,410 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.ldap.role; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; +import org.keycloak.models.RoleModel; +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.common.StreamUtils; +import org.keycloak.models.map.common.StringKeyConvertor; +import org.keycloak.models.map.role.MapRoleEntity; + +import org.keycloak.models.map.storage.ModelCriteriaBuilder; +import org.keycloak.models.map.storage.QueryParameters; +import org.keycloak.models.map.storage.chm.MapFieldPredicates; +import org.keycloak.models.map.storage.chm.MapModelCriteriaBuilder; +import org.keycloak.models.map.storage.ldap.MapModelCriteriaBuilderAssumingEqualForField; +import org.keycloak.models.map.storage.ldap.role.entity.LdapMapRoleEntityFieldDelegate; +import org.keycloak.models.map.storage.ldap.store.LdapMapIdentityStore; +import org.keycloak.models.map.storage.ldap.config.LdapMapConfig; +import org.keycloak.models.map.storage.ldap.LdapMapKeycloakTransaction; +import org.keycloak.models.map.storage.ldap.model.LdapMapDn; +import org.keycloak.models.map.storage.ldap.model.LdapMapObject; +import org.keycloak.models.map.storage.ldap.model.LdapMapQuery; +import org.keycloak.models.map.storage.ldap.role.config.LdapMapRoleMapperConfig; +import org.keycloak.models.map.storage.ldap.role.entity.LdapRoleEntity; + +import javax.naming.NamingException; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class LdapRoleMapKeycloakTransaction extends LdapMapKeycloakTransaction { + + private final KeycloakSession session; + private final StringKeyConvertor keyConverter = new StringKeyConvertor.StringKey(); + private final Set deletedKeys = new HashSet<>(); + private final LdapMapRoleMapperConfig roleMapperConfig; + private final LdapMapConfig ldapMapConfig; + private final LdapMapIdentityStore identityStore; + + public LdapRoleMapKeycloakTransaction(KeycloakSession session, Config.Scope config) { + this.session = session; + this.roleMapperConfig = new LdapMapRoleMapperConfig(config); + this.ldapMapConfig = new LdapMapConfig(config); + this.identityStore = new LdapMapIdentityStore(session, ldapMapConfig); + } + + // interface matching the constructor of this class + public interface LdapRoleMapKeycloakTransactionFunction { + R apply(A a, B b); + } + + // TODO: entries might get stale if a DN of an entry changes due to changes in the entity in the same transaction + private final Map dns = new HashMap<>(); + + public String readIdByDn(String dn) { + // TODO: this might not be necessary if the LDAP server would support an extended OID + // https://ldapwiki.com/wiki/LDAP_SERVER_EXTENDED_DN_OID + + String id = dns.get(dn); + if (id == null) { + for (Map.Entry entry : entities.entrySet()) { + LdapMapObject ldap = entry.getValue().getLdapMapObject(); + if (ldap.getDn().toString().equals(dn)) { + id = ldap.getId(); + break; + } + } + } + if (id != null) { + return id; + } + + LdapMapQuery ldapQuery = new LdapMapQuery(); + + // For now, use same search scope, which is configured "globally" and used for user's search. + ldapQuery.setSearchScope(ldapMapConfig.getSearchScope()); + ldapQuery.setSearchDn(roleMapperConfig.getCommonRolesDn()); + + // TODO: read them properly to be able to store them in the transaction so they are cached?! + Collection roleObjectClasses = ldapMapConfig.getRoleObjectClasses(); + ldapQuery.addObjectClasses(roleObjectClasses); + + String rolesRdnAttr = roleMapperConfig.getRoleNameLdapAttribute(); + + ldapQuery.addReturningLdapAttribute(rolesRdnAttr); + + LdapMapDn.RDN rdn = LdapMapDn.fromString(dn).getFirstRdn(); + String key = rdn.getAllKeys().get(0); + String value = rdn.getAttrValue(key); + + LdapRoleModelCriteriaBuilder mcb = + new LdapRoleModelCriteriaBuilder(roleMapperConfig).compare(RoleModel.SearchableFields.NAME, ModelCriteriaBuilder.Operator.EQ, value); + mcb = mcb.withCustomFilter(roleMapperConfig.getCustomLdapFilter()); + ldapQuery.setModelCriteriaBuilder(mcb); + + List ldapObjects = identityStore.fetchQueryResults(ldapQuery); + if (ldapObjects.size() == 1) { + dns.put(dn, ldapObjects.get(0).getId()); + return ldapObjects.get(0).getId(); + } + return null; + } + + private MapModelCriteriaBuilder createCriteriaBuilderMap() { + // The realmId might not be set of instances retrieved by read(id) and we're still sure that they belong to the realm being searched. + // Therefore, ignore the field realmId when searching the instances that are stored within the transaction. + return new MapModelCriteriaBuilderAssumingEqualForField<>(keyConverter, MapFieldPredicates.getPredicates(RoleModel.class), RoleModel.SearchableFields.REALM_ID); + } + + @Override + public LdapMapRoleEntityFieldDelegate create(MapRoleEntity value) { + DeepCloner CLONER = new DeepCloner.Builder() + .constructor(MapRoleEntity.class, cloner -> new LdapMapRoleEntityFieldDelegate(new LdapRoleEntity(cloner, roleMapperConfig, this, value.getClientId()))) + .build(); + + LdapMapRoleEntityFieldDelegate mapped = (LdapMapRoleEntityFieldDelegate) CLONER.from(value); + + // LDAP should never use the UUID provided by the caller, as UUID is generated by the LDAP directory + mapped.setId(null); + // Roles as groups need to have at least one member on most directories. Add ourselves as a member as a dummy. + if (mapped.getLdapMapObject().getId() == null && mapped.getLdapMapObject().getAttributeAsSet(roleMapperConfig.getMembershipLdapAttribute()) == null) { + // insert our own name as dummy member of this role to avoid a schema conflict in LDAP + mapped.getLdapMapObject().setAttribute(roleMapperConfig.getMembershipLdapAttribute(), Stream.of(mapped.getLdapMapObject().getDn().toString()).collect(Collectors.toSet())); + } + + try { + // in order to get the ID, we need to write it to LDAP + identityStore.add(mapped.getLdapMapObject()); + // TODO: add a flag for temporary created roles until they are finally committed so that they don't show up in ready(query) in their temporary state + } catch (ModelException ex) { + if (value.isClientRole() && ex.getCause() instanceof NamingException) { + // the client hasn't been created, therefore adding it here + LdapMapObject client = new LdapMapObject(); + client.setObjectClasses(Arrays.asList("top", "organizationalUnit")); + client.setRdnAttributeName("ou"); + client.setDn(LdapMapDn.fromString(roleMapperConfig.getRolesDn(mapped.isClientRole(), mapped.getClientId()))); + client.setSingleAttribute("ou", mapped.getClientId()); + identityStore.add(client); + + tasksOnRollback.add(new DeleteOperation() { + @Override + public void execute() { + identityStore.remove(client); + } + }); + + // retry creation of client role + identityStore.add(mapped.getLdapMapObject()); + } + } + + entities.put(mapped.getId(), mapped); + + tasksOnRollback.add(new DeleteOperation() { + @Override + public void execute() { + identityStore.remove(mapped.getLdapMapObject()); + entities.remove(mapped.getId()); + } + }); + + return mapped; + } + + @Override + public boolean delete(String key) { + LdapMapRoleEntityFieldDelegate read = read(key); + if (read == null) { + throw new ModelException("unable to read entity with key " + key); + } + deletedKeys.add(key); + tasksOnCommit.add(new DeleteOperation() { + @Override + public void execute() { + identityStore.remove(read.getLdapMapObject()); + } + }); + return true; + } + + public LdapRoleEntity readLdap(String key) { + LdapMapRoleEntityFieldDelegate read = read(key); + if (read == null) { + return null; + } else { + return read.getEntityFieldDelegate(); + } + } + + @Override + public LdapMapRoleEntityFieldDelegate read(String key) { + if (deletedKeys.contains(key)) { + return null; + } + + // reuse an existing live entity + LdapMapRoleEntityFieldDelegate val = entities.get(key); + + if (val == null) { + + // try to look it up as a realm role + val = lookupEntityById(key, null); + + if (val == null) { + // try to find out the client ID + LdapMapQuery ldapQuery = new LdapMapQuery(); + + // For now, use same search scope, which is configured "globally" and used for user's search. + ldapQuery.setSearchScope(ldapMapConfig.getSearchScope()); + + // remove prefix with placeholder to allow for a broad search + String sdn = roleMapperConfig.getClientRolesDn(); + ldapQuery.setSearchDn(sdn.replaceAll(".*\\{0},", "")); + + LdapMapObject ldapObject = identityStore.fetchById(key, ldapQuery); + if (ldapObject != null) { + // as the client ID is now known, search again with the specific configuration + LdapMapDn.RDN firstRdn = ldapObject.getDn().getParentDn().getFirstRdn(); + String clientId = firstRdn.getAttrValue(firstRdn.getAllKeys().get(0)); + // lookup with clientId, as the search above might have been broader than a restricted search + val = lookupEntityById(key, clientId); + } + } + + if (val != null) { + entities.put(key, val); + } + + } + return val; + } + + private LdapMapRoleEntityFieldDelegate lookupEntityById(String id, String clientId) { + LdapMapQuery ldapQuery = getLdapQuery(clientId != null, clientId); + + LdapMapObject ldapObject = identityStore.fetchById(id, ldapQuery); + if (ldapObject != null) { + return new LdapMapRoleEntityFieldDelegate(new LdapRoleEntity(ldapObject, roleMapperConfig, this, clientId)); + } + return null; + } + + @Override + public Stream read(QueryParameters queryParameters) { + LdapRoleModelCriteriaBuilder mcb = queryParameters.getModelCriteriaBuilder() + .flashToModelCriteriaBuilder(createLdapModelCriteriaBuilder()); + + Boolean isClientRole = mcb.isClientRole(); + String clientId = mcb.getClientId(); + + LdapMapQuery ldapQuery = getLdapQuery(isClientRole, clientId); + + mcb = mcb.withCustomFilter(roleMapperConfig.getCustomLdapFilter()); + ldapQuery.setModelCriteriaBuilder(mcb); + + Stream ldapStream; + + MapModelCriteriaBuilder mapMcb = queryParameters.getModelCriteriaBuilder().flashToModelCriteriaBuilder(createCriteriaBuilderMap()); + + Stream existingEntities = entities.entrySet().stream() + .filter(me -> mapMcb.getKeyFilter().test(keyConverter.fromString(me.getKey())) && !deletedKeys.contains(me.getKey())) + .map(Map.Entry::getValue) + .filter(mapMcb.getEntityFilter()) + // snapshot list + .collect(Collectors.toList()).stream(); + + // current approach: combine the results in a correct way from existing entities in the transaction and LDAP + // problem here: pagination doesn't work any more as results are retrieved from both, and then need to be sorted + // possible alternative: use search criteria only on LDAP, and replace found entities with those stored in transaction already + // this will then not find additional entries modified or created in this transaction + + try { + List ldapObjects = identityStore.fetchQueryResults(ldapQuery); + + ldapStream = ldapObjects.stream().map(ldapMapObject -> { + // we might have fetch client and realm roles at the same time, now try to decode what is what + StreamUtils.Pair client = getClientId(ldapMapObject.getDn()); + if (client == null) { + return null; + } + LdapMapRoleEntityFieldDelegate entity = new LdapMapRoleEntityFieldDelegate(new LdapRoleEntity(ldapMapObject, roleMapperConfig, this, client.getV())); + LdapMapRoleEntityFieldDelegate existingEntry = entities.get(entity.getId()); + if (existingEntry != null) { + // this entry will be part of the existing entities + return null; + } + entities.put(entity.getId(), entity); + return (MapRoleEntity) entity; + }) + .filter(Objects::nonNull) + .filter(me -> !deletedKeys.contains(me.getId())) + // re-apply filters about client roles that we might have skipped for LDAP + .filter(me -> mapMcb.getKeyFilter().test(me.getId())) + .filter(me -> mapMcb.getEntityFilter().test(me)) + // snapshot list, as the contents depends on entities and also updates the entities, + // and two streams open at the same time could otherwise interfere + .collect(Collectors.toList()).stream(); + } catch (ModelException ex) { + if (clientId != null && ex.getCause() instanceof NamingException) { + // the client wasn't found in LDAP, assume an empty result + ldapStream = Stream.empty(); + } else { + throw ex; + } + } + + ldapStream = Stream.concat(ldapStream, existingEntities); + + if (!queryParameters.getOrderBy().isEmpty()) { + ldapStream = ldapStream.sorted(MapFieldPredicates.getComparator(queryParameters.getOrderBy().stream())); + } + if (queryParameters.getOffset() != null) { + ldapStream = ldapStream.skip(queryParameters.getOffset()); + } + if (queryParameters.getLimit() != null) { + ldapStream = ldapStream.limit(queryParameters.getLimit()); + } + + return ldapStream; + } + + private StreamUtils.Pair getClientId(LdapMapDn dn) { + if (dn.getParentDn().equals(LdapMapDn.fromString(roleMapperConfig.getRealmRolesDn()))) { + return new StreamUtils.Pair<>(false, null); + } + String clientsDnWildcard = roleMapperConfig.getClientRolesDn(); + if (clientsDnWildcard != null) { + clientsDnWildcard = clientsDnWildcard.replaceAll(".*\\{0},", ""); + if (dn.getParentDn().getParentDn().equals(LdapMapDn.fromString(clientsDnWildcard))) { + LdapMapDn.RDN firstRdn = dn.getParentDn().getFirstRdn(); + return new StreamUtils.Pair<>(true, firstRdn.getAttrValue(firstRdn.getAllKeys().get(0))); + } + } + return null; + } + + private LdapMapQuery getLdapQuery(Boolean isClientRole, String clientId) { + LdapMapQuery ldapMapQuery = new LdapMapQuery(); + + // For now, use same search scope, which is configured "globally" and used for user's search. + ldapMapQuery.setSearchScope(ldapMapConfig.getSearchScope()); + + String rolesDn = roleMapperConfig.getRolesDn(isClientRole, clientId); + ldapMapQuery.setSearchDn(rolesDn); + + Collection roleObjectClasses = ldapMapConfig.getRoleObjectClasses(); + ldapMapQuery.addObjectClasses(roleObjectClasses); + + String rolesRdnAttr = roleMapperConfig.getRoleNameLdapAttribute(); + + ldapMapQuery.addReturningLdapAttribute(rolesRdnAttr); + ldapMapQuery.addReturningLdapAttribute("description"); + ldapMapQuery.addReturningLdapAttribute(roleMapperConfig.getMembershipLdapAttribute()); + roleMapperConfig.getRoleAttributes().forEach(ldapMapQuery::addReturningLdapAttribute); + return ldapMapQuery; + } + + @Override + public void commit() { + super.commit(); + for (MapTaskWithValue mapTaskWithValue : tasksOnCommit) { + mapTaskWithValue.execute(); + } + + entities.forEach((entityKey, entity) -> { + if (entity.isUpdated()) { + identityStore.update(entity.getLdapMapObject()); + } + }); + } + + @Override + public void rollback() { + super.rollback(); + Iterator iterator = tasksOnRollback.descendingIterator(); + while (iterator.hasNext()) { + iterator.next().execute(); + } + } + + protected LdapRoleModelCriteriaBuilder createLdapModelCriteriaBuilder() { + return new LdapRoleModelCriteriaBuilder(roleMapperConfig); + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleModelCriteriaBuilder.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleModelCriteriaBuilder.java new file mode 100644 index 0000000000..962e93b347 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleModelCriteriaBuilder.java @@ -0,0 +1,225 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.ldap.role; + +import org.keycloak.models.ModelException; +import org.keycloak.models.RoleModel; +import org.keycloak.models.map.storage.CriterionNotSupportedException; +import org.keycloak.models.map.storage.ldap.LdapModelCriteriaBuilder; +import org.keycloak.models.map.storage.ldap.store.LdapMapEscapeStrategy; +import org.keycloak.models.map.storage.ldap.role.config.LdapMapRoleMapperConfig; +import org.keycloak.models.map.storage.ldap.role.entity.LdapRoleEntity; +import org.keycloak.storage.SearchableModelField; + +import java.util.ArrayList; +import java.util.Objects; +import java.util.function.Supplier; + +public class LdapRoleModelCriteriaBuilder extends LdapModelCriteriaBuilder { + + private final LdapMapRoleMapperConfig roleMapperConfig; + + public String getClientId() { + return clientId; + } + + public Boolean isClientRole() { + return isClientRole; + } + + public String getRealmId() { + return realmId; + } + + private String clientId; + + private Boolean isClientRole; + + private String realmId; + + @Override + public LdapRoleModelCriteriaBuilder and(LdapRoleModelCriteriaBuilder... builders) { + LdapRoleModelCriteriaBuilder and = super.and(builders); + for (LdapRoleModelCriteriaBuilder builder : builders) { + if (builder.isClientRole != null) { + if (and.isClientRole != null && !Objects.equals(and.isClientRole, builder.isClientRole)) { + throw new ModelException("isClientRole must be specified in query only once"); + } + and.isClientRole = builder.isClientRole; + } + if (builder.clientId != null) { + if (and.clientId != null && !Objects.equals(and.clientId, builder.clientId)) { + throw new ModelException("clientId must be specified in query only once"); + } + and.clientId = builder.clientId; + } + if (builder.realmId != null) { + if (and.realmId != null && !Objects.equals(and.realmId, builder.realmId)) { + throw new ModelException("realmId must be specified in query only once"); + } + and.realmId = builder.realmId; + } + } + return and; + } + + @Override + public LdapRoleModelCriteriaBuilder or(LdapRoleModelCriteriaBuilder... builders) { + LdapRoleModelCriteriaBuilder or = super.or(builders); + for (LdapRoleModelCriteriaBuilder builder : builders) { + if (builder.isClientRole != null) { + throw new ModelException("isClientRole not supported in OR condition"); + } + if (builder.clientId != null) { + throw new ModelException("clientId not supported in OR condition"); + } + if (builder.realmId != null) { + throw new ModelException("realmId not supported in OR condition"); + } + } + return or; + } + + @Override + public LdapRoleModelCriteriaBuilder not(LdapRoleModelCriteriaBuilder builder) { + LdapRoleModelCriteriaBuilder not = super.not(builder); + if (builder.isClientRole != null) { + throw new ModelException("isClientRole not supported in NOT condition"); + } + if (builder.clientId != null) { + throw new ModelException("clientId not supported in NOT condition"); + } + if (builder.realmId != null) { + throw new ModelException("realmId not supported in NOT condition"); + } + return not; + } + + public LdapRoleModelCriteriaBuilder(LdapMapRoleMapperConfig roleMapperConfig) { + super(predicateFunc -> new LdapRoleModelCriteriaBuilder(roleMapperConfig, predicateFunc)); + this.roleMapperConfig = roleMapperConfig; + } + + private LdapRoleModelCriteriaBuilder(LdapMapRoleMapperConfig roleMapperConfig, Supplier predicateFunc) { + super(pf -> new LdapRoleModelCriteriaBuilder(roleMapperConfig, pf), predicateFunc); + this.roleMapperConfig = roleMapperConfig; + } + + @Override + public LdapRoleModelCriteriaBuilder compare(SearchableModelField modelField, Operator op, Object... value) { + switch (op) { + case EQ: + if (modelField.equals(RoleModel.SearchableFields.IS_CLIENT_ROLE)) { + LdapRoleModelCriteriaBuilder result = new LdapRoleModelCriteriaBuilder(roleMapperConfig, StringBuilder::new); + result.isClientRole = (boolean) value[0]; + return result; + } else if (modelField.equals(RoleModel.SearchableFields.CLIENT_ID)) { + LdapRoleModelCriteriaBuilder result = new LdapRoleModelCriteriaBuilder(roleMapperConfig, StringBuilder::new); + result.clientId = (String) value[0]; + return result; + } else if (modelField.equals(RoleModel.SearchableFields.REALM_ID)) { + LdapRoleModelCriteriaBuilder result = new LdapRoleModelCriteriaBuilder(roleMapperConfig, StringBuilder::new); + result.realmId = (String) value[0]; + return result; + } else if (modelField.equals(RoleModel.SearchableFields.NAME)) { + // validateValue(value, modelField, op, String.class); + String field = modelFieldNameToLdap(roleMapperConfig, modelField); + return new LdapRoleModelCriteriaBuilder(roleMapperConfig, + () -> equal(field, value[0], LdapMapEscapeStrategy.DEFAULT, false)); + } else { + throw new CriterionNotSupportedException(modelField, op); + } + + case NE: + if (modelField.equals(RoleModel.SearchableFields.IS_CLIENT_ROLE)) { + LdapRoleModelCriteriaBuilder result = new LdapRoleModelCriteriaBuilder(roleMapperConfig, StringBuilder::new); + result.isClientRole = !((boolean) value[0]); + return result; + } else if (modelField.equals(RoleModel.SearchableFields.NAME)) { + // validateValue(value, modelField, op, String.class); + String field = modelFieldNameToLdap(roleMapperConfig, modelField); + return not(new LdapRoleModelCriteriaBuilder(roleMapperConfig, + () -> equal(field, value[0], LdapMapEscapeStrategy.DEFAULT, false))); + } else { + throw new CriterionNotSupportedException(modelField, op); + } + + case ILIKE: + case LIKE: + if (modelField.equals(RoleModel.SearchableFields.NAME) || + modelField.equals(RoleModel.SearchableFields.DESCRIPTION)) { + // validateValue(value, modelField, op, String.class); + // first escape all elements of the string (which would not escape the percent sign) + // then replace percent sign with the wildcard character asterisk + // the result should then be used unescaped in the condition. + String v = LdapMapEscapeStrategy.DEFAULT.escape(String.valueOf(value[0])).replaceAll("%", "*"); + // TODO: there is no placeholder for a single character wildcard ... use multicharacter wildcard instead? + String field = modelFieldNameToLdap(roleMapperConfig, modelField); + return new LdapRoleModelCriteriaBuilder(roleMapperConfig, () -> { + if (v.equals("**")) { + // wildcard everything is not well-understood by LDAP and will result in "ERR_01101_NULL_LENGTH The length should not be 0" + return new StringBuilder(); + } else { + return equal(field, v, LdapMapEscapeStrategy.NON_ASCII_CHARS_ONLY, false); + } + }); + } else { + throw new CriterionNotSupportedException(modelField, op); + } + + case IN: + if (modelField.equals(RoleModel.SearchableFields.NAME) || + modelField.equals(RoleModel.SearchableFields.DESCRIPTION) || + modelField.equals(RoleModel.SearchableFields.ID)) { + String field = modelFieldNameToLdap(roleMapperConfig, modelField); + return new LdapRoleModelCriteriaBuilder(roleMapperConfig, () -> { + Object[] v; + if (value[0] instanceof ArrayList) { + v = ((ArrayList) value[0]).toArray(); + } else { + throw new CriterionNotSupportedException(modelField, op); + } + return in(field, v, false); + }); + } else { + throw new CriterionNotSupportedException(modelField, op); + } + + default: + throw new CriterionNotSupportedException(modelField, op); + } + } + + private String modelFieldNameToLdap(LdapMapRoleMapperConfig roleMapperConfig, SearchableModelField modelField) { + if (modelField.equals(RoleModel.SearchableFields.NAME)) { + return roleMapperConfig.getRoleNameLdapAttribute(); + } else if (modelField.equals(RoleModel.SearchableFields.ID)) { + return roleMapperConfig.getLdapMapConfig().getUuidLDAPAttributeName(); + } else if (modelField.equals(RoleModel.SearchableFields.DESCRIPTION)) { + return "description"; + } else { + throw new CriterionNotSupportedException(modelField, null); + } + } + + public LdapRoleModelCriteriaBuilder withCustomFilter(String customFilter) { + if (customFilter != null && toString().length() > 0) { + return and(this, new LdapRoleModelCriteriaBuilder(roleMapperConfig, () -> new StringBuilder(customFilter))); + } + return this; + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/config/LdapMapRoleMapperConfig.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/config/LdapMapRoleMapperConfig.java new file mode 100644 index 0000000000..bb07aa35ed --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/config/LdapMapRoleMapperConfig.java @@ -0,0 +1,157 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.role.config; + +import org.keycloak.Config; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.models.map.storage.ldap.config.LdapMapCommonGroupMapperConfig; +import org.keycloak.models.map.storage.ldap.config.LdapMapConfig; +import org.keycloak.models.map.storage.ldap.model.LdapMapDn; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; + +public class LdapMapRoleMapperConfig extends LdapMapCommonGroupMapperConfig { + + private final Config.Scope config; + private final LdapMapConfig ldapMapConfig; + + public LdapMapRoleMapperConfig(Config.Scope config) { + super(new ComponentModel() { + @Override + public MultivaluedHashMap getConfig() { + return new MultivaluedHashMap() { + @Override + public String getFirst(String key) { + return config.get(key); + } + }; + } + }); + this.config = config; + this.ldapMapConfig = new LdapMapConfig(config); + } + + public String getRealmRolesDn() { + String rolesDn = config.get(REALM_ROLES_DN); + if (rolesDn == null) { + throw new ModelException("Roles DN is null! Check your configuration"); + } + return rolesDn; + } + + public String getCommonRolesDn() { + String rolesDn = config.get(COMMON_ROLES_DN); + if (rolesDn == null) { + throw new ModelException("Roles DN is null! Check your configuration"); + } + return rolesDn; + } + + public String getClientRolesDn() { + String rolesDn = config.get(CLIENT_ROLES_DN); + if (rolesDn == null) { + throw new ModelException("Roles DN is null! Check your configuration"); + } + return rolesDn; + } + + public String getRolesDn(Boolean isClientRole, String clientId) { + String rolesDn; + if (isClientRole == null && clientId == null) { + rolesDn = mapperModel.getConfig().getFirst(COMMON_ROLES_DN); + } else { + if (isClientRole != null && !isClientRole) { + rolesDn = config.get(REALM_ROLES_DN); + } else { + rolesDn = config.get(CLIENT_ROLES_DN); + if (rolesDn != null) { + LdapMapDn dn = LdapMapDn.fromString(rolesDn); + LdapMapDn.RDN firstRdn = dn.getFirstRdn(); + for (String key : firstRdn.getAllKeys()) { + firstRdn.setAttrValue(key, firstRdn.getAttrValue(key).replaceAll("\\{0}", Matcher.quoteReplacement(clientId))); + } + rolesDn = dn.toString(); + } + } + } + if (rolesDn == null) { + throw new ModelException("Roles DN is null! Check your configuration"); + } + return rolesDn; + } + + public Set getRoleAttributes() { + String roleAttributes = mapperModel.getConfig().getFirst("role.attributes"); + if (roleAttributes == null) { + roleAttributes = ""; + } + return new HashSet<>(Arrays.asList(roleAttributes.trim().split("\\s+"))); + } + + // LDAP DN where are realm roles of this tree saved. + public static final String REALM_ROLES_DN = "roles.realm.dn"; + + // LDAP DN where are client roles of this tree saved. + public static final String CLIENT_ROLES_DN = "roles.client.dn"; + + // LDAP DN to find both client and realm roles. + public static final String COMMON_ROLES_DN = "roles.common.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"; + + // 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 String getRoleNameLdapAttribute() { + String rolesRdnAttr = mapperModel.getConfig().getFirst(ROLE_NAME_LDAP_ATTRIBUTE); + return rolesRdnAttr!=null ? rolesRdnAttr : LDAPConstants.CN; + } + + @Override + public String getLDAPGroupNameLdapAttribute() { + return getRoleNameLdapAttribute(); + } + + public String getCustomLdapFilter() { + return mapperModel.getConfig().getFirst(ROLES_LDAP_FILTER); + } + + public String getUserRolesRetrieveStrategy() { + String strategyString = mapperModel.getConfig().getFirst(USER_ROLES_RETRIEVE_STRATEGY); + return strategyString!=null ? strategyString : LOAD_ROLES_BY_MEMBER_ATTRIBUTE; + } + + public LdapMapConfig getLdapMapConfig() { + return ldapMapConfig; + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapMapRoleEntityFieldDelegate.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapMapRoleEntityFieldDelegate.java new file mode 100644 index 0000000000..778bb15663 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapMapRoleEntityFieldDelegate.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.role.entity; + +import org.keycloak.models.map.common.delegate.EntityFieldDelegate; +import org.keycloak.models.map.role.MapRoleEntity; +import org.keycloak.models.map.role.MapRoleEntityFieldDelegate; +import org.keycloak.models.map.storage.ldap.model.LdapMapObject; + +public class LdapMapRoleEntityFieldDelegate extends MapRoleEntityFieldDelegate { + + public LdapMapRoleEntityFieldDelegate(EntityFieldDelegate entityFieldDelegate) { + super(entityFieldDelegate); + } + + @Override + public LdapRoleEntity getEntityFieldDelegate() { + return (LdapRoleEntity) super.getEntityFieldDelegate(); + } + + @Override + public boolean isUpdated() { + // TODO: EntityFieldDelegate.isUpdated is broken, as it is never updated + return getEntityFieldDelegate().isUpdated(); + } + + public LdapMapObject getLdapMapObject() { + return getEntityFieldDelegate().getLdapMapObject(); + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapRoleEntity.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapRoleEntity.java new file mode 100644 index 0000000000..0acda09640 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapRoleEntity.java @@ -0,0 +1,335 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.ldap.role.entity; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.apache.commons.lang.NotImplementedException; +import org.keycloak.models.ModelException; +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.common.EntityField; +import org.keycloak.models.map.common.UpdatableEntity; +import org.keycloak.models.map.common.delegate.EntityFieldDelegate; +import org.keycloak.models.map.role.MapRoleEntity; +import org.keycloak.models.map.role.MapRoleEntityFields; +import org.keycloak.models.map.storage.ldap.model.LdapMapDn; +import org.keycloak.models.map.storage.ldap.model.LdapMapObject; +import org.keycloak.models.map.storage.ldap.role.config.LdapMapRoleMapperConfig; +import org.keycloak.models.map.storage.ldap.role.LdapRoleMapKeycloakTransaction; + +public class LdapRoleEntity extends UpdatableEntity.Impl implements EntityFieldDelegate { + + private final LdapMapObject ldapMapObject; + private final LdapMapRoleMapperConfig roleMapperConfig; + private final LdapRoleMapKeycloakTransaction transaction; + private final String clientId; + + private static final EnumMap> SETTERS = new EnumMap<>(MapRoleEntityFields.class); + static { + SETTERS.put(MapRoleEntityFields.DESCRIPTION, (e, v) -> e.setDescription((String) v)); + SETTERS.put(MapRoleEntityFields.ID, (e, v) -> e.setId((String) v)); + SETTERS.put(MapRoleEntityFields.REALM_ID, (e, v) -> e.setRealmId((String) v)); + SETTERS.put(MapRoleEntityFields.CLIENT_ID, (e, v) -> e.setClientId((String) v)); + SETTERS.put(MapRoleEntityFields.CLIENT_ROLE, (e, v) -> e.setClientRole((Boolean) v)); + //noinspection unchecked + SETTERS.put(MapRoleEntityFields.ATTRIBUTES, (e, v) -> e.setAttributes((Map>) v)); + //noinspection unchecked + SETTERS.put(MapRoleEntityFields.COMPOSITE_ROLES, (e, v) -> e.setCompositeRoles((Set) v)); + SETTERS.put(MapRoleEntityFields.NAME, (e, v) -> e.setName((String) v)); + } + + private static final EnumMap> GETTERS = new EnumMap<>(MapRoleEntityFields.class); + static { + GETTERS.put(MapRoleEntityFields.DESCRIPTION, LdapRoleEntity::getDescription); + GETTERS.put(MapRoleEntityFields.ID, LdapRoleEntity::getId); + GETTERS.put(MapRoleEntityFields.REALM_ID, LdapRoleEntity::getRealmId); + GETTERS.put(MapRoleEntityFields.CLIENT_ID, LdapRoleEntity::getClientId); + GETTERS.put(MapRoleEntityFields.CLIENT_ROLE, LdapRoleEntity::isClientRole); + GETTERS.put(MapRoleEntityFields.ATTRIBUTES, LdapRoleEntity::getAttributes); + GETTERS.put(MapRoleEntityFields.COMPOSITE_ROLES, LdapRoleEntity::getCompositeRoles); + GETTERS.put(MapRoleEntityFields.NAME, LdapRoleEntity::getName); + } + + private static final EnumMap> ADDERS = new EnumMap<>(MapRoleEntityFields.class); + static { + ADDERS.put(MapRoleEntityFields.COMPOSITE_ROLES, (e, v) -> e.addCompositeRole((String) v)); + } + + private static final EnumMap> REMOVERS = new EnumMap<>(MapRoleEntityFields.class); + static { + REMOVERS.put(MapRoleEntityFields.COMPOSITE_ROLES, (e, v) -> { e.removeCompositeRole((String) v); return null; }); + } + + public LdapRoleEntity(DeepCloner cloner, LdapMapRoleMapperConfig roleMapperConfig, LdapRoleMapKeycloakTransaction transaction, String clientId) { + ldapMapObject = new LdapMapObject(); + ldapMapObject.setObjectClasses(Arrays.asList("top", "groupOfNames")); + ldapMapObject.setRdnAttributeName(roleMapperConfig.getRoleNameLdapAttribute()); + this.roleMapperConfig = roleMapperConfig; + this.transaction = transaction; + this.clientId = clientId; + } + + public LdapRoleEntity(LdapMapObject ldapMapObject, LdapMapRoleMapperConfig roleMapperConfig, LdapRoleMapKeycloakTransaction transaction, String clientId) { + this.ldapMapObject = ldapMapObject; + this.roleMapperConfig = roleMapperConfig; + this.transaction = transaction; + this.clientId = clientId; + } + + public String getId() { + return ldapMapObject.getId(); + } + + public void setId(String id) { + this.updated |= !Objects.equals(getId(), id); + ldapMapObject.setId(id); + } + + + public Map> getAttributes() { + Map> result = new HashMap<>(); + for (String roleAttribute : roleMapperConfig.getRoleAttributes()) { + Set attrs = ldapMapObject.getAttributeAsSet(roleAttribute); + if (attrs != null) { + result.put(roleAttribute, new ArrayList<>(attrs)); + } + } + return result; + } + + public void setAttributes(Map> attributes) { + // store all attributes + if (attributes != null) { + attributes.forEach(this::setAttribute); + } + // clear attributes not in the list + for (String roleAttribute : roleMapperConfig.getRoleAttributes()) { + if (attributes == null || !attributes.containsKey(roleAttribute)) { + removeAttribute(roleAttribute); + } + } + } + + public List getAttribute(String name) { + if (!roleMapperConfig.getRoleAttributes().contains(name)) { + throw new ModelException("can't read attribute '" + name + "' as it is not supported"); + } + return new ArrayList<>(ldapMapObject.getAttributeAsSet(name)); + } + + public void setAttribute(String name, List value) { + if (!roleMapperConfig.getRoleAttributes().contains(name)) { + throw new ModelException("can't set attribute '" + name + "' as it is not supported"); + } + if ((ldapMapObject.getAttributeAsSet(name) == null && (value == null || value.size() == 0)) || + Objects.equals(ldapMapObject.getAttributeAsSet(name), new HashSet<>(value))) { + return; + } + if (ldapMapObject.getReadOnlyAttributeNames().contains(name)) { + throw new ModelException("can't write attribute '" + name + "' as it is not writeable"); + } + ldapMapObject.setAttribute(name, new HashSet<>(value)); + this.updated = true; + } + + public void removeAttribute(String name) { + if (!roleMapperConfig.getRoleAttributes().contains(name)) { + throw new ModelException("can't write attribute '" + name + "' as it is not supported"); + } + if (ldapMapObject.getAttributeAsSet(name) == null || ldapMapObject.getAttributeAsSet(name).size() == 0) { + return; + } + ldapMapObject.setAttribute(name, null); + this.updated = true; + } + + public String getRealmId() { + return null; + } + + public String getClientId() { + return clientId; + } + + public String getName() { + return ldapMapObject.getAttributeAsString(roleMapperConfig.getRoleNameLdapAttribute()); + } + + public String getDescription() { + return ldapMapObject.getAttributeAsString("description"); + } + + public void setClientRole(Boolean clientRole) { + if (!Objects.equals(this.isClientRole(), clientRole)) { + throw new NotImplementedException(); + } + } + + public boolean isClientRole() { + return clientId != null; + } + + public void setRealmId(String realmId) { + // we'll not store this information, as LDAP store might be used from different realms + } + + public void setClientId(String clientId) { + if (!Objects.equals(this.getClientId(), clientId)) { + throw new NotImplementedException(); + } + } + + public void setName(String name) { + this.updated |= !Objects.equals(getName(), name); + ldapMapObject.setSingleAttribute(roleMapperConfig.getRoleNameLdapAttribute(), name); + LdapMapDn dn = LdapMapDn.fromString(roleMapperConfig.getRolesDn(clientId != null, clientId)); + dn.addFirst(roleMapperConfig.getRoleNameLdapAttribute(), name); + ldapMapObject.setDn(dn); + } + + public void setDescription(String description) { + this.updated |= !Objects.equals(getDescription(), description); + if (description != null) { + ldapMapObject.setSingleAttribute("description", description); + } else if (getDescription() != null) { + ldapMapObject.setAttribute("description", null); + } + } + + public Set getCompositeRoles() { + Set members = ldapMapObject.getAttributeAsSet(roleMapperConfig.getMembershipLdapAttribute()); + if (members == null) { + members = new HashSet<>(); + } + HashSet compositeRoles = new HashSet<>(); + for (String member : members) { + if (member.equals(ldapMapObject.getDn().toString())) { + continue; + } + if (!member.startsWith(roleMapperConfig.getRoleNameLdapAttribute())) { + // this is a real user, not a composite role, ignore + // TODO: this will not work if users and role use the same! + continue; + } + String roleId = transaction.readIdByDn(member); + if (roleId == null) { + throw new NotImplementedException(); + } + compositeRoles.add(roleId); + } + return compositeRoles; + } + + public void setCompositeRoles(Set compositeRoles) { + HashSet translatedCompositeRoles = new HashSet<>(); + if (compositeRoles != null) { + for (String compositeRole : compositeRoles) { + LdapRoleEntity ldapRole = transaction.readLdap(compositeRole); + translatedCompositeRoles.add(ldapRole.getLdapMapObject().getDn().toString()); + } + } + Set members = ldapMapObject.getAttributeAsSet(roleMapperConfig.getMembershipLdapAttribute()); + if (members == null) { + members = new HashSet<>(); + } + for (String member : members) { + if (!member.startsWith(roleMapperConfig.getRoleNameLdapAttribute())) { + // this is a real user, not a composite role, ignore + // TODO: this will not work if users and role use the same! + translatedCompositeRoles.add(member); + } + } + if (!translatedCompositeRoles.equals(members)) { + ldapMapObject.setAttribute(roleMapperConfig.getMembershipLdapAttribute(), members); + this.updated = true; + } + } + + public void addCompositeRole(String roleId) { + LdapRoleEntity ldapRole = transaction.readLdap(roleId); + Set members = ldapMapObject.getAttributeAsSet(roleMapperConfig.getMembershipLdapAttribute()); + if (members == null) { + members = new HashSet<>(); + } + members.add(ldapRole.getLdapMapObject().getDn().toString()); + ldapMapObject.setAttribute(roleMapperConfig.getMembershipLdapAttribute(), members); + this.updated = true; + } + + public void removeCompositeRole(String roleId) { + LdapRoleEntity ldapRole = transaction.readLdap(roleId); + Set members = ldapMapObject.getAttributeAsSet(roleMapperConfig.getMembershipLdapAttribute()); + if (members == null) { + members = new HashSet<>(); + } + members.remove(ldapRole.getLdapMapObject().getDn().toString()); + ldapMapObject.setAttribute(roleMapperConfig.getMembershipLdapAttribute(), members); + this.updated = true; + } + + public LdapMapObject getLdapMapObject() { + return ldapMapObject; + } + + @Override + public > & EntityField> void set(EF field, T value) { + BiConsumer consumer = SETTERS.get(field); + if (consumer == null) { + throw new ModelException("unsupported field for setters " + field); + } + consumer.accept(this, value); + } + + @Override + public > & EntityField> void collectionAdd(EF field, T value) { + BiConsumer consumer = ADDERS.get(field); + if (consumer == null) { + throw new ModelException("unsupported field for setters " + field); + } + consumer.accept(this, value); + } + + @Override + public > & EntityField> Object collectionRemove(EF field, T value) { + BiFunction consumer = REMOVERS.get(field); + if (consumer == null) { + throw new ModelException("unsupported field for setters " + field); + } + return consumer.apply(this, value); + } + + @Override + public > & EntityField> Object get(EF field) { + Function consumer = GETTERS.get(field); + if (consumer == null) { + throw new ModelException("unsupported field for getters " + field); + } + return consumer.apply(this); + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapContextManager.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapContextManager.java new file mode 100644 index 0000000000..535eed6233 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapContextManager.java @@ -0,0 +1,273 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.store; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.map.storage.ldap.config.LdapMapConfig; +import org.keycloak.truststore.TruststoreProvider; +import org.keycloak.vault.VaultCharSecret; + +import javax.naming.AuthenticationException; +import javax.naming.Context; +import javax.naming.NamingException; +import javax.naming.ldap.InitialLdapContext; +import javax.naming.ldap.LdapContext; +import javax.naming.ldap.StartTlsRequest; +import javax.naming.ldap.StartTlsResponse; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.nio.CharBuffer; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import static javax.naming.Context.SECURITY_CREDENTIALS; + +/** + * @author mhajas + */ +public final class LdapMapContextManager implements AutoCloseable { + + private static final Logger logger = Logger.getLogger(LdapMapContextManager.class); + + private final KeycloakSession session; + private final LdapMapConfig ldapMapConfig; + private StartTlsResponse tlsResponse; + + private VaultCharSecret vaultCharSecret = new VaultCharSecret() { + @Override + public Optional get() { + return Optional.empty(); + } + + @Override + public Optional getAsArray() { + return Optional.empty(); + } + + @Override + public void close() { + + } + }; + + private LdapContext ldapContext; + + public LdapMapContextManager(KeycloakSession session, LdapMapConfig connectionProperties) { + this.session = session; + this.ldapMapConfig = connectionProperties; + } + + public static LdapMapContextManager create(KeycloakSession session, LdapMapConfig connectionProperties) { + return new LdapMapContextManager(session, connectionProperties); + } + + private void createLdapContext() throws NamingException { + Hashtable connProp = getConnectionProperties(ldapMapConfig); + + if (!LDAPConstants.AUTH_TYPE_NONE.equals(ldapMapConfig.getAuthType())) { + vaultCharSecret = getVaultSecret(); + + if (vaultCharSecret != null && !ldapMapConfig.isStartTls()) { + connProp.put(SECURITY_CREDENTIALS, vaultCharSecret.getAsArray() + .orElse(ldapMapConfig.getBindCredential().toCharArray())); + } + } + + ldapContext = new InitialLdapContext(connProp, null); + if (ldapMapConfig.isStartTls()) { + SSLSocketFactory sslSocketFactory = null; + String useTruststoreSpi = ldapMapConfig.getUseTruststoreSpi(); + if (useTruststoreSpi != null && useTruststoreSpi.equals(LDAPConstants.USE_TRUSTSTORE_ALWAYS)) { + TruststoreProvider provider = session.getProvider(TruststoreProvider.class); + sslSocketFactory = provider.getSSLSocketFactory(); + } + + tlsResponse = startTLS(ldapContext, ldapMapConfig.getAuthType(), ldapMapConfig.getBindDN(), + vaultCharSecret.getAsArray().orElse(ldapMapConfig.getBindCredential().toCharArray()), sslSocketFactory); + + // Exception should be already thrown by LDAPContextManager.startTLS if "startTLS" could not be established, but rather do some additional check + if (tlsResponse == null) { + throw new NamingException("Wasn't able to establish LDAP connection through StartTLS"); + } + } + } + + public LdapContext getLdapContext() throws NamingException { + if (ldapContext == null) createLdapContext(); + + return ldapContext; + } + + private VaultCharSecret getVaultSecret() { + return LDAPConstants.AUTH_TYPE_NONE.equals(ldapMapConfig.getAuthType()) + ? null + : session.vault().getCharSecret(ldapMapConfig.getBindCredential()); + } + + public static StartTlsResponse startTLS(LdapContext ldapContext, String authType, String bindDN, char[] bindCredential, SSLSocketFactory sslSocketFactory) throws NamingException { + StartTlsResponse tls; + + try { + tls = (StartTlsResponse) ldapContext.extendedOperation(new StartTlsRequest()); + tls.negotiate(sslSocketFactory); + + ldapContext.addToEnvironment(Context.SECURITY_AUTHENTICATION, authType); + + if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) { + ldapContext.addToEnvironment(Context.SECURITY_PRINCIPAL, bindDN); + ldapContext.addToEnvironment(Context.SECURITY_CREDENTIALS, bindCredential); + } + } catch (Exception e) { + logger.error("Could not negotiate TLS", e); + throw new AuthenticationException("Could not negotiate TLS"); + } + + // throws AuthenticationException when authentication fails + ldapContext.lookup(""); + + return tls; + } + + // Get connection properties of admin connection + private Hashtable getConnectionProperties(LdapMapConfig ldapMapConfig) { + Hashtable env = getNonAuthConnectionProperties(ldapMapConfig); + + if(!ldapMapConfig.isStartTls()) { + String authType = ldapMapConfig.getAuthType(); + + env.put(Context.SECURITY_AUTHENTICATION, authType); + + String bindDN = ldapMapConfig.getBindDN(); + + char[] bindCredential = null; + + if (ldapMapConfig.getBindCredential() != null) { + bindCredential = ldapMapConfig.getBindCredential().toCharArray(); + } + + if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) { + env.put(Context.SECURITY_PRINCIPAL, bindDN); + env.put(Context.SECURITY_CREDENTIALS, bindCredential); + } + } + + if (logger.isTraceEnabled()) { + Map copyEnv = new Hashtable<>(env); + if (copyEnv.containsKey(Context.SECURITY_CREDENTIALS)) { + copyEnv.put(Context.SECURITY_CREDENTIALS, "**************************************"); + } + logger.tracef("Creating LdapContext using properties: [%s]", copyEnv); + } + + return env; + } + + + /** + * This method is used for admin connection and user authentication. Hence it returns just connection properties NOT related to + * authentication (properties like bindType, bindDn, bindPassword). Caller of this method needs to fill auth-related connection properties + * based on the fact whether he does admin connection or user authentication + * + */ + public static Hashtable getNonAuthConnectionProperties(LdapMapConfig ldapMapConfig) { + HashMap env = new HashMap<>(); + + env.put(Context.INITIAL_CONTEXT_FACTORY, ldapMapConfig.getFactoryName()); + + String url = ldapMapConfig.getConnectionUrl(); + + if (url != null) { + env.put(Context.PROVIDER_URL, url); + } else { + logger.warn("LDAP URL is null. LDAPOperationManager won't work correctly"); + } + + // when using Start TLS, use default socket factory for LDAP client but pass the TrustStore SSL socket factory later + // when calling StartTlsResponse.negotiate(trustStoreSSLSocketFactory) + if (!ldapMapConfig.isStartTls()) { + String useTruststoreSpi = ldapMapConfig.getUseTruststoreSpi(); + LDAPConstants.setTruststoreSpiIfNeeded(useTruststoreSpi, url, env); + } + + String connectionPooling = ldapMapConfig.getConnectionPooling(); + if (connectionPooling != null) { + env.put("com.sun.jndi.ldap.connect.pool", connectionPooling); + } + + String connectionTimeout = ldapMapConfig.getConnectionTimeout(); + if (connectionTimeout != null && !connectionTimeout.isEmpty()) { + env.put("com.sun.jndi.ldap.connect.timeout", connectionTimeout); + } + + String readTimeout = ldapMapConfig.getReadTimeout(); + if (readTimeout != null && !readTimeout.isEmpty()) { + env.put("com.sun.jndi.ldap.read.timeout", readTimeout); + } + + // Just dump the additional properties + Properties additionalProperties = ldapMapConfig.getAdditionalConnectionProperties(); + if (additionalProperties != null) { + for (Object key : additionalProperties.keySet()) { + env.put(key.toString(), additionalProperties.getProperty(key.toString())); + } + } + + StringBuilder binaryAttrsBuilder = new StringBuilder(); + if (ldapMapConfig.isObjectGUID()) { + binaryAttrsBuilder.append(LDAPConstants.OBJECT_GUID).append(" "); + } + if (ldapMapConfig.isEdirectory()) { + binaryAttrsBuilder.append(LDAPConstants.NOVELL_EDIRECTORY_GUID).append(" "); + } + for (String attrName : ldapMapConfig.getBinaryAttributeNames()) { + binaryAttrsBuilder.append(attrName).append(" "); + } + + String binaryAttrs = binaryAttrsBuilder.toString().trim(); + if (!binaryAttrs.isEmpty()) { + env.put("java.naming.ldap.attributes.binary", binaryAttrs); + } + + return new Hashtable<>(env); + } + + @Override + public void close() { + if (vaultCharSecret != null) vaultCharSecret.close(); + if (tlsResponse != null) { + try { + tlsResponse.close(); + } catch (IOException e) { + logger.error("Could not close Ldap tlsResponse.", e); + } + } + + if (ldapContext != null) { + try { + ldapContext.close(); + } catch (NamingException e) { + logger.error("Could not close Ldap context.", e); + } + } + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapEscapeStrategy.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapEscapeStrategy.java new file mode 100644 index 0000000000..36468aadd5 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapEscapeStrategy.java @@ -0,0 +1,111 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.store; + +import java.nio.charset.StandardCharsets; + +/** + * @author Marek Posolda + */ +public enum LdapMapEscapeStrategy { + + + // LDAP special characters like * ( ) \ are not escaped. Only non-ASCII characters like é are escaped + NON_ASCII_CHARS_ONLY { + + @Override + public String escape(String input) { + StringBuilder output = new StringBuilder(); + + for (byte b : input.getBytes(StandardCharsets.UTF_8)) { + appendByte(b, output); + } + + return output.toString(); + } + + }, + + + // Escaping of LDAP special characters including non-ASCII characters like é + DEFAULT { + + + @Override + public String escape(String input) { + StringBuilder output = new StringBuilder(); + + for (byte b : input.getBytes(StandardCharsets.UTF_8)) { + switch (b) { + case 0x5c: + output.append("\\5c"); // \ + break; + case 0x2a: + output.append("\\2a"); // * + break; + case 0x28: + output.append("\\28"); // ( + break; + case 0x29: + output.append("\\29"); // ) + break; + case 0x00: + output.append("\\00"); // \u0000 + break; + default: { + appendByte(b, output); + } + } + } + + return output.toString(); + } + + }, + + // Escaping value as Octet-String + OCTET_STRING { + @Override + public String escape(String input) { + byte[] bytes; + bytes = input.getBytes(StandardCharsets.UTF_8); + return escapeHex(bytes); + } + + }; + + public static String escapeHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("\\%02x", b)); + } + return sb.toString(); + } + + public abstract String escape(String input); + + protected void appendByte(byte b, StringBuilder output) { + if (b >= 0) { + output.append((char) b); + } else { + int i = -256 ^ b; + output.append("\\").append(Integer.toHexString(i)); + } + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapIdentityStore.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapIdentityStore.java new file mode 100644 index 0000000000..ff694e9c34 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapIdentityStore.java @@ -0,0 +1,533 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.store; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.Base64; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.models.map.storage.ldap.config.LdapMapConfig; +import org.keycloak.models.map.storage.ldap.model.LdapMapDn; +import org.keycloak.models.map.storage.ldap.model.LdapMapObject; +import org.keycloak.models.map.storage.ldap.model.LdapMapQuery; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation; +import org.keycloak.representations.idm.LDAPCapabilityRepresentation.CapabilityType; + +import javax.naming.AuthenticationException; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.AttributeInUseException; +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.NoSuchAttributeException; +import javax.naming.directory.SchemaViolationException; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import java.io.IOException; +import java.util.ArrayList; +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; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * An IdentityStore implementation backed by an LDAP directory + * + * @author Shane Bryzak + * @author Anil Saldhana + * @author Pedro Silva + */ +public class LdapMapIdentityStore { + + private static final Logger logger = Logger.getLogger(LdapMapIdentityStore.class); + private static final Pattern rangePattern = Pattern.compile("([^;]+);range=([0-9]+)-([0-9]+|\\*)"); + + private final LdapMapConfig config; + private final LdapMapOperationManager operationManager; + + public LdapMapIdentityStore(KeycloakSession session, LdapMapConfig config) { + this.config = config; + this.operationManager = new LdapMapOperationManager(session, config); + } + + public LdapMapConfig getConfig() { + return this.config; + } + + public void add(LdapMapObject ldapObject) { + // id will be assigned by the ldap server + if (ldapObject.getId() != null) { + throw new ModelException("Can't add object with already assigned uuid"); + } + + String entryDN = ldapObject.getDn().toString(); + BasicAttributes ldapAttributes = extractAttributesForSaving(ldapObject, true); + this.operationManager.createSubContext(entryDN, ldapAttributes); + ldapObject.setId(getEntryIdentifier(ldapObject)); + + if (logger.isDebugEnabled()) { + logger.debugf("Type with identifier [%s] and dn [%s] successfully added to LDAP store.", ldapObject.getId(), entryDN); + } + } + + public void addMemberToGroup(String groupDn, String memberAttrName, String value) { + // do not check EMPTY_MEMBER_ATTRIBUTE_VALUE, we save one useless query + // the value will be there forever for objectclasses that enforces the attribute as MUST + BasicAttribute attr = new BasicAttribute(memberAttrName, value); + ModificationItem item = new ModificationItem(DirContext.ADD_ATTRIBUTE, attr); + try { + this.operationManager.modifyAttributesNaming(groupDn, new ModificationItem[]{item}, null); + } catch (AttributeInUseException e) { + logger.debugf("Group %s already contains the member %s", groupDn, value); + } catch (NamingException e) { + throw new ModelException("Could not modify attribute for DN [" + groupDn + "]", e); + } + } + + public void removeMemberFromGroup(String groupDn, String memberAttrName, String value) { + BasicAttribute attr = new BasicAttribute(memberAttrName, value); + ModificationItem item = new ModificationItem(DirContext.REMOVE_ATTRIBUTE, attr); + try { + this.operationManager.modifyAttributesNaming(groupDn, new ModificationItem[]{item}, null); + } catch (NoSuchAttributeException e) { + logger.debugf("Group %s does not contain the member %s", groupDn, value); + } catch (SchemaViolationException e) { + // schema violation removing one member => add the empty attribute, it cannot be other thing + logger.infof("Schema violation in group %s removing member %s. Trying adding empty member attribute.", groupDn, value); + try { + this.operationManager.modifyAttributesNaming(groupDn, + new ModificationItem[]{item, new ModificationItem(DirContext.ADD_ATTRIBUTE, new BasicAttribute(memberAttrName, LDAPConstants.EMPTY_MEMBER_ATTRIBUTE_VALUE))}, + null); + } catch (NamingException ex) { + throw new ModelException("Could not modify attribute for DN [" + groupDn + "]", ex); + } + } catch (NamingException e) { + throw new ModelException("Could not modify attribute for DN [" + groupDn + "]", e); + } + } + + public void update(LdapMapObject ldapObject) { + checkRename(ldapObject); + + BasicAttributes updatedAttributes = extractAttributesForSaving(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.getId(), entryDn); + } + } + + protected void checkRename(LdapMapObject ldapObject) { + LdapMapDn.RDN firstRdn = ldapObject.getDn().getFirstRdn(); + String oldDn = ldapObject.getDn().toString(); + + // Detect which keys will need to be updated in RDN, which are new keys to be added, and which are to be removed + List toUpdateKeys = firstRdn.getAllKeys(); + toUpdateKeys.retainAll(ldapObject.getRdnAttributeNames()); + + List toRemoveKeys = firstRdn.getAllKeys(); + toRemoveKeys.removeAll(ldapObject.getRdnAttributeNames()); + + List toAddKeys = new ArrayList<>(ldapObject.getRdnAttributeNames()); + toAddKeys.removeAll(firstRdn.getAllKeys()); + + // Go through all the keys in the oldRDN and doublecheck if they are changed or not + boolean changed = false; + for (String attrKey : toUpdateKeys) { + if (ldapObject.getReadOnlyAttributeNames().contains(attrKey.toLowerCase())) { + continue; + } + + String rdnAttrVal = ldapObject.getAttributeAsString(attrKey); + + // Could be the case when RDN attribute of the target object is not included in Keycloak mappers + if (rdnAttrVal == null) { + continue; + } + + String oldRdnAttrVal = firstRdn.getAttrValue(attrKey); + + if (!oldRdnAttrVal.equalsIgnoreCase(rdnAttrVal)) { + changed = true; + firstRdn.setAttrValue(attrKey, rdnAttrVal); + } + } + + // Add new keys + for (String attrKey : toAddKeys) { + String rdnAttrVal = ldapObject.getAttributeAsString(attrKey); + + // Could be the case when RDN attribute of the target object is not included in Keycloak mappers + if (rdnAttrVal == null) { + continue; + } + + changed = true; + firstRdn.setAttrValue(attrKey, rdnAttrVal); + } + + // Remove old keys + for (String attrKey : toRemoveKeys) { + changed |= firstRdn.removeAttrValue(attrKey); + } + + if (changed) { + LdapMapDn newLdapMapDn = ldapObject.getDn().getParentDn(); + newLdapMapDn.addFirst(firstRdn); + + String newDn = newLdapMapDn.toString(); + + logger.debugf("Renaming LDAP Object. Old DN: [%s], New DN: [%s]", oldDn, newDn); + + // In case, that there is conflict (For example already existing "CN=John Anthony"), the different DN is returned + newDn = this.operationManager.renameEntry(oldDn, newDn, true); + + ldapObject.setDn(LdapMapDn.fromString(newDn)); + } + } + + public void remove(LdapMapObject 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.getId(), ldapObject.getDn().toString()); + } + } + + public LdapMapObject fetchById(String id, LdapMapQuery identityQuery) { + SearchResult search = this.operationManager + .lookupById(identityQuery.getSearchDn(), id, identityQuery.getReturningLdapAttributes()); + + if (search != null) { + return populateAttributedType(search, identityQuery); + } else { + return null; + } + } + + public List fetchQueryResults(LdapMapQuery identityQuery) { + List results = new ArrayList<>(); + + StringBuilder filter = null; + + try { + String baseDN = identityQuery.getSearchDn(); + + filter = createIdentityTypeSearchFilter(identityQuery); + + List search; + search = this.operationManager.search(baseDN, filter.toString(), identityQuery.getReturningLdapAttributes(), identityQuery.getSearchScope()); + + for (SearchResult result : search) { + // don't add the branch in subtree search + if (identityQuery.getSearchScope() != SearchControls.SUBTREE_SCOPE || !result.getNameInNamespace().equalsIgnoreCase(baseDN)) { + results.add(populateAttributedType(result, identityQuery)); + } + } + } catch (Exception e) { + throw new ModelException("Querying of LDAP failed " + identityQuery + ", filter: " + filter, e); + } + + return results; + } + + public Set queryServerCapabilities() { + Set result = new LinkedHashSet<>(); + try { + List attrs = new ArrayList<>(); + attrs.add("supportedControl"); + attrs.add("supportedExtension"); + attrs.add("supportedFeatures"); + List searchResults = operationManager + .search("", "(objectClass=*)", Collections.unmodifiableCollection(attrs), SearchControls.OBJECT_SCOPE); + if (searchResults.size() != 1) { + throw new ModelException("Could not query root DSE: unexpected result size"); + } + SearchResult rootDse = searchResults.get(0); + Attributes attributes = rootDse.getAttributes(); + for (String attr: attrs) { + Attribute attribute = attributes.get(attr); + if (null != attribute) { + CapabilityType capabilityType = CapabilityType.fromRootDseAttributeName(attr); + NamingEnumeration values = attribute.getAll(); + while (values.hasMoreElements()) { + Object o = values.nextElement(); + LDAPCapabilityRepresentation capability = new LDAPCapabilityRepresentation(o, capabilityType); + logger.info("rootDSE query: " + capability); + result.add(capability); + } + } + } + return result; + } catch (NamingException e) { + throw new ModelException("Failed to query root DSE: " + e.getMessage(), e); + } + } + + // *************** CREDENTIALS AND USER SPECIFIC STUFF + + public void validatePassword(LdapMapObject 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); + } + + // ************ END CREDENTIALS AND USER SPECIFIC STUFF + + protected StringBuilder createIdentityTypeSearchFilter(final LdapMapQuery identityQuery) { + StringBuilder filter = identityQuery.getModelCriteriaBuilder().getPredicateFunc().get(); + + 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 LdapMapObject populateAttributedType(SearchResult searchResult, LdapMapQuery 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(); + + LdapMapObject ldapObject = new LdapMapObject(); + LdapMapDn dn = LdapMapDn.fromString(entryDN); + ldapObject.setDn(dn); + ldapObject.setRdnAttributeNames(dn.getFirstRdn().getAllKeys()); + + NamingEnumeration ldapAttributes = attributes.getAll(); + + while (ldapAttributes.hasMore()) { + Attribute ldapAttribute = ldapAttributes.next(); + + try { + ldapAttribute.get(); + } catch (NoSuchElementException nsee) { + continue; + } + + String ldapAttributeName = ldapAttribute.getID(); + + // check for ranged attribute + Matcher m = rangePattern.matcher(ldapAttributeName); + if (m.matches()) { + ldapAttributeName = m.group(1); + // range=X-* means all the attributes returned + if (!m.group(3).equals("*")) { + try { + int max = Integer.parseInt(m.group(3)); + ldapObject.addRangedAttribute(ldapAttributeName, max); + } catch (NumberFormatException e) { + logger.warnf("Invalid ranged expresion for attribute: %s", m.group(0)); + } + } + } + + if (ldapAttributeName.equalsIgnoreCase(getConfig().getUuidLDAPAttributeName())) { + Object uuidValue = ldapAttribute.get(); + ldapObject.setId(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()) { + Object val = enumm.next(); + + if (val instanceof byte[]) { // byte[] + String attrVal = Base64.encodeBytes((byte[]) val); + attrValues.add(attrVal); + } else { // String + String attrVal = val.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 extractAttributesForSaving(LdapMapObject ldapObject, boolean isCreate) { + BasicAttributes entryAttributes = new BasicAttributes(); + + Set rdnAttrNamesLowerCased = ldapObject.getRdnAttributeNames().stream() + .map(String::toLowerCase) + .collect(Collectors.toSet()); + + for (Map.Entry> attrEntry : ldapObject.getAttributes().entrySet()) { + String attrName = attrEntry.getKey(); + Set attrValue = attrEntry.getValue(); + + 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(); + } + + String attrNameLowercased = attrName.toLowerCase(); + if ( + // Ignore empty attributes on create (changetype: add) + !(isCreate && attrValue.isEmpty()) && + + // Since we're extracting for saving, skip read-only attributes. ldapObject.getReadOnlyAttributeNames() are lower-cased + !ldapObject.getReadOnlyAttributeNames().contains(attrNameLowercased) && + + // Only extract RDN for create since it can't be changed on update + (isCreate || !rdnAttrNamesLowerCased.contains(attrNameLowercased)) + ) { + if (getConfig().getBinaryAttributeNames().contains(attrName)) { + // Binary attribute + entryAttributes.put(createBinaryBasicAttribute(attrName, attrValue)); + } else { + // Text attribute + entryAttributes.put(createBasicAttribute(attrName, attrValue)); + } + } + } + + // Don't extract object classes for update + if (isCreate) { + BasicAttribute objectClassAttribute = new BasicAttribute(LDAPConstants.OBJECT_CLASS); + + for (String objectClassValue : ldapObject.getObjectClasses()) { + objectClassAttribute.add(objectClassValue); + } + + entryAttributes.put(objectClassAttribute); + } + + return entryAttributes; + } + + private BasicAttribute createBasicAttribute(String attrName, Set attrValue) { + BasicAttribute attr = new BasicAttribute(attrName); + + for (String value : attrValue) { + if (value == null || value.trim().length() == 0) { + value = LDAPConstants.EMPTY_ATTRIBUTE_VALUE; + } + + attr.add(value); + } + + return attr; + } + + private BasicAttribute createBinaryBasicAttribute(String attrName, Set attrValue) { + BasicAttribute attr = new BasicAttribute(attrName); + + for (String value : attrValue) { + if (value == null || value.trim().length() == 0) { + value = LDAPConstants.EMPTY_ATTRIBUTE_VALUE; + } + + try { + byte[] bytes = Base64.decode(value); + attr.add(bytes); + } catch (IOException ioe) { + logger.warnf("Wasn't able to Base64 decode the attribute value. Ignoring attribute update. Attribute: %s, Attribute value: %s", attrName, attrValue); + } + } + + return attr; + } + + protected String getEntryIdentifier(final LdapMapObject ldapObject) { + try { + // we need this to retrieve the entry's identifier from the ldap server + String uuidAttrName = getConfig().getUuidLDAPAttributeName(); + + String rdn = ldapObject.getDn().getFirstRdn().toString(false); + String filter = "(" + LdapMapEscapeStrategy.DEFAULT.escape(rdn) + ")"; + List search = this.operationManager.search(ldapObject.getDn().toString(), filter, Collections.singletonList(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/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOctetStringEncoder.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOctetStringEncoder.java new file mode 100644 index 0000000000..9176fc8105 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOctetStringEncoder.java @@ -0,0 +1,56 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.store; + +public class LdapMapOctetStringEncoder { + + private final LdapMapEscapeStrategy fallback; + + public LdapMapOctetStringEncoder() { + this(null); + } + + public LdapMapOctetStringEncoder(LdapMapEscapeStrategy fallback) { + this.fallback = fallback; + } + + + public String encode(Object parameterValue, boolean isBinary) { + String escaped; + if (parameterValue instanceof byte[]) { + escaped = LdapMapEscapeStrategy.escapeHex((byte[]) parameterValue); + } else { + escaped = escapeAsString(parameterValue, isBinary); + } + return escaped; + } + + private String escapeAsString(Object parameterValue, boolean isBinary) { + String escaped; + String stringValue = parameterValue.toString(); + if (isBinary) { + escaped = LdapMapEscapeStrategy.OCTET_STRING.escape(stringValue); + } else if (fallback == null){ + escaped = stringValue; + } else { + escaped = fallback.escape(stringValue); + } + return escaped; + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationDecorator.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationDecorator.java new file mode 100644 index 0000000000..1a2891bf1a --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationDecorator.java @@ -0,0 +1,30 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.store; + +import javax.naming.NamingException; +import javax.naming.ldap.LdapContext; + +/** + * @author Marek Posolda + */ +public interface LdapMapOperationDecorator { + + void beforeLDAPOperation(LdapContext ldapContext, LdapMapOperationManager.LdapOperation ldapOperation) throws NamingException; + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationManager.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationManager.java new file mode 100644 index 0000000000..21330b00ff --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationManager.java @@ -0,0 +1,629 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.store; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.ModelException; +import org.keycloak.models.map.storage.ldap.config.LdapMapConfig; +import org.keycloak.models.map.storage.ldap.model.LdapMapDn; +import org.keycloak.truststore.TruststoreProvider; + +import javax.naming.AuthenticationException; +import javax.naming.Binding; +import javax.naming.Context; +import javax.naming.NameAlreadyBoundException; +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.InitialLdapContext; +import javax.naming.ldap.LdapContext; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.StartTlsResponse; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Hashtable; +import java.util.List; +import java.util.Set; + +/** + *

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

+ * + * @author Anil Saldhana + * @author Pedro Silva + */ +public class LdapMapOperationManager { + + private static final Logger logger = Logger.getLogger(LdapMapOperationManager.class); + + private static final Logger perfLogger = Logger.getLogger(LdapMapOperationManager.class, "perf"); + + private final KeycloakSession session; + private final LdapMapConfig config; + + public LdapMapOperationManager(KeycloakSession session, LdapMapConfig config) { + this.session = session; + this.config = config; + } + + /** + *

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

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

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

+ * + */ + 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[] {}), null); + } 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. + *

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

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

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

+ * Removes the object from the LDAP tree + *

+ */ + public void removeEntry(final String entryDn) { + try { + execute(new LdapOperation() { + + @Override + public SearchResult execute(LdapContext context) { + if (logger.isTraceEnabled()) { + logger.tracef("Removing entry with DN [%s]", entryDn); + } + destroySubcontext(context, entryDn); + return null; + } + + + @Override + public String toString() { + return "LdapOperation: remove\n" + + " dn: " + entryDn; + } + + }); + } catch (NamingException e) { + throw new ModelException("Could not remove entry from DN [" + entryDn + "]", e); + } + } + + + /** + * Rename LDAPObject name (DN) + * + * @param fallback With fallback=true, we will try to find the another DN in case of conflict. For example if there is an + * attempt to rename to "CN=John Doe", but there is already existing "CN=John Doe", we will try "CN=John Doe0" + * @return the non-conflicting DN, which was used in the end + */ + public String renameEntry(String oldDn, String newDn, boolean fallback) { + try { + return execute(new LdapOperation() { + + @Override + public String execute(LdapContext context) throws NamingException { + String dn = newDn; + + // Max 5 attempts for now + int max = 5; + for (int i=0 ; i search(final String baseDN, final String filter, Collection returningAttributes, int searchScope) throws NamingException { + final List result = new ArrayList<>(); + final SearchControls cons = getSearchControls(returningAttributes, searchScope); + + return execute(new LdapOperation>() { + @Override + public List execute(LdapContext context) throws NamingException { + NamingEnumeration search = context.search(new LdapName(baseDN), filter, cons); + + while (search.hasMoreElements()) { + result.add(search.nextElement()); + } + + search.close(); + + return result; + } + + + @Override + public String toString() { + return "LdapOperation: search\n" + + " baseDn: " + baseDN + "\n" + + " filter: " + filter + "\n" + + " searchScope: " + searchScope + "\n" + + " returningAttrs: " + returningAttributes + "\n" + + " resultSize: " + result.size(); + } + + + }); + } + + 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[0])); + return cons; + } + + public String getFilterById(String id) { + StringBuilder filter = new StringBuilder(); + filter.insert(0, "(&"); + + if (this.config.isObjectGUID()) { + byte[] objectGUID = LdapMapUtil.encodeObjectGUID(id); + filter.append("(objectClass=*)(").append( + getUuidAttributeName()).append(LDAPConstants.EQUAL) + .append(LdapMapUtil.convertObjectGUIDToByteString( + objectGUID)).append(")"); + + } else if (this.config.isEdirectoryGUID()) { + filter.append("(objectClass=*)(").append(getUuidAttributeName().toUpperCase()) + .append(LDAPConstants.EQUAL + ).append(LdapMapUtil.convertGUIDToEdirectoryHexString(id)).append(")"); + } else { + filter.append("(objectClass=*)(").append(getUuidAttributeName()).append(LDAPConstants.EQUAL) + .append(id).append(")"); + } + + if (config.getCustomUserSearchFilter() != null) { + filter.append(config.getCustomUserSearchFilter()); + } + + filter.append(")"); + String ldapIdFilter = filter.toString(); + + logger.tracef("Using filter for lookup user by LDAP ID: %s", ldapIdFilter); + + return ldapIdFilter; + } + + 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(new LdapName(baseDN), filter, cons); + + try { + if (search.hasMoreElements()) { + return search.next(); + } + } finally { + if (search != null) { + search.close(); + } + } + + return null; + } + + + @Override + public String toString() { + return "LdapOperation: lookupById\n" + + " baseDN: " + baseDN + "\n" + + " filter: " + filter + "\n" + + " searchScope: " + cons.getSearchScope() + "\n" + + " returningAttrs: " + returningAttributes; + } + + }); + } 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. + *

+ */ + private void destroySubcontext(LdapContext context, final String dn) { + try { + NamingEnumeration enumeration = null; + + try { + enumeration = context.listBindings(new LdapName(dn)); + + while (enumeration.hasMore()) { + Binding binding = enumeration.next(); + String name = binding.getNameInNamespace(); + + destroySubcontext(context, name); + } + + context.unbind(new LdapName(dn)); + } finally { + if (enumeration != null) { + try { + enumeration.close(); + } catch (Exception e) { + logger.warn("problem during close", 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. + *

+ * + * @throws AuthenticationException if authentication is not successful + * + */ + public void authenticate(String dn, String password) throws AuthenticationException { + + if (password == null || password.isEmpty()) { + throw new AuthenticationException("Empty password used"); + } + + LdapContext authCtx = null; + StartTlsResponse tlsResponse = null; + + try { + + Hashtable env = LdapMapContextManager.getNonAuthConnectionProperties(config); + + // Never use connection pool to prevent password caching + env.put("com.sun.jndi.ldap.connect.pool", "false"); + + if(!this.config.isStartTls()) { + env.put(Context.SECURITY_AUTHENTICATION, "simple"); + env.put(Context.SECURITY_PRINCIPAL, dn); + env.put(Context.SECURITY_CREDENTIALS, password); + } + + authCtx = new InitialLdapContext(env, null); + if (config.isStartTls()) { + SSLSocketFactory sslSocketFactory = null; + String useTruststoreSpi = config.getUseTruststoreSpi(); + if (useTruststoreSpi != null && useTruststoreSpi.equals(LDAPConstants.USE_TRUSTSTORE_ALWAYS)) { + TruststoreProvider provider = session.getProvider(TruststoreProvider.class); + sslSocketFactory = provider.getSSLSocketFactory(); + } + + tlsResponse = LdapMapContextManager.startTLS(authCtx, "simple", dn, password.toCharArray(), sslSocketFactory); + + // Exception should be already thrown by LDAPContextManager.startTLS if "startTLS" could not be established, but rather do some additional check + if (tlsResponse == null) { + throw new AuthenticationException("Null TLS Response returned from the authentication"); + } + } + } catch (AuthenticationException ae) { + if (logger.isDebugEnabled()) { + logger.debugf(ae, "Authentication failed for DN [%s]", dn); + } + + throw ae; + } catch(RuntimeException re){ + if (logger.isDebugEnabled()) { + logger.debugf(re, "LDAP Connection TimeOut for DN [%s]", dn); + } + + throw re; + + } 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 (tlsResponse != null) { + try { + tlsResponse.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + if (authCtx != null) { + try { + authCtx.close(); + } catch (NamingException e) { + e.printStackTrace(); + } + } + } + } + + public void modifyAttributesNaming(final String dn, final ModificationItem[] mods, LdapMapOperationDecorator decorator) throws NamingException { + 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"; + } + + String attrName = item.getAttribute().getID().toUpperCase(); + if (attrName.contains("PASSWORD") || attrName.contains("UNICODEPWD")) { + 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(new LdapName(dn), mods); + return null; + } + + @Override + public String toString() { + return "LdapOperation: modify\n" + + " dn: " + dn + "\n" + + " modificationsSize: " + mods.length; + } + + }, decorator); + } + + public void modifyAttributes(final String dn, final ModificationItem[] mods, LdapMapOperationDecorator decorator) { + try { + modifyAttributesNaming(dn, mods, decorator); + } 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(); + + String attrName = attribute.getID().toUpperCase(); + Object attrVal = attribute.get(); + if (attrName.contains("PASSWORD") || attrName.contains("UNICODEPWD")) { + attrVal = "********************"; + } + + logger.tracef(" %s = %s", attribute.getID(), attrVal); + } + + logger.tracef("]"); + } + + execute(new LdapOperation() { + @Override + public Void execute(LdapContext context) throws NamingException { + DirContext subcontext = context.createSubcontext(new LdapName(name), attributes); + + subcontext.close(); + + return null; + } + + + @Override + public String toString() { + return "LdapOperation: create\n" + + " dn: " + name + "\n" + + " attributesSize: " + attributes.size(); + } + + }); + } 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) { + if (entryUUID instanceof byte[]) { + if (this.config.isObjectGUID()) { + return LdapMapUtil.decodeObjectGUID((byte[]) entryUUID); + } + if (this.config.isEdirectory() && this.config.isEdirectoryGUID()) { + return LdapMapUtil.decodeGuid((byte[]) entryUUID); + } + } + return entryUUID.toString(); + } + + private R execute(LdapOperation operation) throws NamingException { + return execute(operation, null); + } + + private R execute(LdapOperation operation, LdapMapOperationDecorator decorator) throws NamingException { + try (LdapMapContextManager ldapMapContextManager = LdapMapContextManager.create(session, config)) { + return execute(operation, ldapMapContextManager.getLdapContext(), decorator); + } + } + + private R execute(LdapOperation operation, LdapContext context, LdapMapOperationDecorator decorator) throws NamingException { + if (context == null) { + throw new IllegalArgumentException("Ldap context cannot be null"); + } + + Long start = null; + + if (perfLogger.isDebugEnabled()) { + start = Time.currentTimeMillis(); + } + + try { + if (decorator != null) { + decorator.beforeLDAPOperation(context, operation); + } + + return operation.execute(context); + } finally { + if (start != null) { + long took = Time.currentTimeMillis() - start; + + if (took > 100) { + perfLogger.debugf("\n%s\ntook: %d ms\n", operation.toString(), took); + } else if (perfLogger.isTraceEnabled()) { + perfLogger.tracef("\n%s\ntook: %d ms\n", operation.toString(), took); + } + } + } + } + + public interface LdapOperation { + R execute(LdapContext context) throws NamingException; + } + + private Set getReturningAttributes(final Collection returningAttributes) { + Set result = new HashSet<>(returningAttributes); + result.add(getUuidAttributeName()); + result.add(LDAPConstants.OBJECT_CLASS); + + return result; + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapUtil.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapUtil.java new file mode 100644 index 0000000000..22c0be47cf --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapUtil.java @@ -0,0 +1,254 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.store; + +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 LdapMapUtil { + + /** + *

Formats the given date.

+ * + * @param date The Date to format. + * + * @return A String representing the formatted date. + */ + public static 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 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 + convertObjectGUIDToByteString(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 convertObjectGUIDToByteString(byte[] objectGUID) { + StringBuilder result = new StringBuilder(); + + for (byte b : objectGUID) { + String transformed = prefixZeros((int) b & 0xFF); + result.append("\\"); + result.append(transformed); + } + + return result.toString(); + } + + /** + * see http://support.novell.com/docs/Tids/Solutions/10096551.html + * + * @param guid A GUID in the form of a dashed String as the result of (@see LDAPUtil#convertToDashedString) + * + * @return A 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 convertGUIDToEdirectoryHexString(String guid) { + String withoutDash = guid.replace("-", ""); + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < withoutDash.length(); i++) { + result.append("\\"); + result.append(withoutDash.charAt(i)); + result.append(withoutDash.charAt(++i)); + } + + return result.toString().toUpperCase(); + } + + /** + *

Encode a string representing the display value of the objectGUID attribute retrieved from Active + * Directory.

+ * + * @param displayString 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]. + * + * @return A raw byte array representing the value of the objectGUID attribute retrieved from + * Active Directory. + */ + public static byte[] encodeObjectGUID(String displayString) { + byte [] objectGUID = new byte[16]; + // [3][2][1][0] + objectGUID[0] = (byte) ((Character.digit(displayString.charAt(6), 16) << 4) + + Character.digit(displayString.charAt(7), 16)); + objectGUID[1] = (byte) ((Character.digit(displayString.charAt(4), 16) << 4) + + Character.digit(displayString.charAt(5), 16)); + objectGUID[2] = (byte) ((Character.digit(displayString.charAt(2), 16) << 4) + + Character.digit(displayString.charAt(3), 16)); + objectGUID[3] = (byte) ((Character.digit(displayString.charAt(0), 16) << 4) + + Character.digit(displayString.charAt(1), 16)); + // [5][4] + objectGUID[4] = (byte) ((Character.digit(displayString.charAt(11), 16) << 4) + + Character.digit(displayString.charAt(12), 16)); + objectGUID[5] = (byte) ((Character.digit(displayString.charAt(9), 16) << 4) + + Character.digit(displayString.charAt(10), 16)); + // [7][6] + objectGUID[6] = (byte) ((Character.digit(displayString.charAt(16), 16) << 4) + + Character.digit(displayString.charAt(17), 16)); + objectGUID[7] = (byte) ((Character.digit(displayString.charAt(14), 16) << 4) + + Character.digit(displayString.charAt(15), 16)); + // [8][9] + objectGUID[8] = (byte) ((Character.digit(displayString.charAt(19), 16) << 4) + + Character.digit(displayString.charAt(20), 16)); + objectGUID[9] = (byte) ((Character.digit(displayString.charAt(21), 16) << 4) + + Character.digit(displayString.charAt(22), 16)); + // [10][11][12][13][14][15] + objectGUID[10] = (byte) ((Character.digit(displayString.charAt(24), 16) << 4) + + Character.digit(displayString.charAt(25), 16)); + objectGUID[11] = (byte) ((Character.digit(displayString.charAt(26), 16) << 4) + + Character.digit(displayString.charAt(27), 16)); + objectGUID[12] = (byte) ((Character.digit(displayString.charAt(28), 16) << 4) + + Character.digit(displayString.charAt(29), 16)); + objectGUID[13] = (byte) ((Character.digit(displayString.charAt(30), 16) << 4) + + Character.digit(displayString.charAt(31), 16)); + objectGUID[14] = (byte) ((Character.digit(displayString.charAt(32), 16) << 4) + + Character.digit(displayString.charAt(33), 16)); + objectGUID[15] = (byte) ((Character.digit(displayString.charAt(34), 16) << 4) + + Character.digit(displayString.charAt(35), 16)); + return objectGUID; + } + + /** + *

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) { + return convertToDashedString(objectGUID); + } + + /** + *

Decode a raw byte array representing the value of the guid attribute retrieved from Novell + * eDirectory.

+ * + * @param guid A raw byte array representing the value of the guid attribute retrieved from + * Novell eDirectory. + * + * @return A string representing the decoded value in the form of [0][1][2][3]-[4][5]-[6][7]-[8][9]-[10][11][12][13][14][15]. + */ + public static String decodeGuid(byte[] guid) { + byte[] withBigEndian = new byte[] { guid[3], guid[2], guid[1], guid[0], + guid[5], guid[4], + guid[7], guid[6], + guid[8], guid[9], guid[10], guid[11], guid[12], guid[13], guid[14], guid[15] + }; + return convertToDashedString(withBigEndian); + } + + private static String convertToDashedString(byte[] objectGUID) { + return prefixZeros((int) objectGUID[3] & 0xFF) + + prefixZeros((int) objectGUID[2] & 0xFF) + + prefixZeros((int) objectGUID[1] & 0xFF) + + prefixZeros((int) objectGUID[0] & 0xFF) + + "-" + + prefixZeros((int) objectGUID[5] & 0xFF) + + prefixZeros((int) objectGUID[4] & 0xFF) + + "-" + + prefixZeros((int) objectGUID[7] & 0xFF) + + prefixZeros((int) objectGUID[6] & 0xFF) + + "-" + + prefixZeros((int) objectGUID[8] & 0xFF) + + prefixZeros((int) objectGUID[9] & 0xFF) + + "-" + + prefixZeros((int) objectGUID[10] & 0xFF) + + prefixZeros((int) objectGUID[11] & 0xFF) + + prefixZeros((int) objectGUID[12] & 0xFF) + + prefixZeros((int) objectGUID[13] & 0xFF) + + prefixZeros((int) objectGUID[14] & 0xFF) + + prefixZeros((int) objectGUID[15] & 0xFF); + } + + private static String prefixZeros(int value) { + if (value <= 0xF) { + return "0" + Integer.toHexString(value); + } else { + return Integer.toHexString(value); + } + } + + +} diff --git a/model/map-ldap/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProviderFactory b/model/map-ldap/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProviderFactory new file mode 100644 index 0000000000..c08e6a1960 --- /dev/null +++ b/model/map-ldap/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProviderFactory @@ -0,0 +1,18 @@ +# +# Copyright 2021 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.models.map.storage.ldap.LdapMapStorageProviderFactory diff --git a/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/Config.java b/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/Config.java new file mode 100644 index 0000000000..a272fbd353 --- /dev/null +++ b/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/Config.java @@ -0,0 +1,62 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap; + +import java.util.HashMap; +import java.util.Map; + +/** + * Simple configuration holder that allows for unit testing. + */ +public class Config extends org.keycloak.Config.SystemPropertiesScope { + + private final Map props; + + private Config(String prefix, Map props) { + super(prefix); + this.props = props; + } + + public Config() { + this("", new HashMap<>()); + } + + public void put(String key, String value) { + props.put(key, value); + } + + @Override + public String get(String key, String defaultValue) { + String val = props.get(prefix + key); + if (val != null) { + return val; + } + return super.get(key, defaultValue); + } + + @Override + public org.keycloak.Config.Scope scope(String... scope) { + StringBuilder sb = new StringBuilder(); + sb.append(prefix).append("."); + for (String s : scope) { + sb.append(s); + sb.append("."); + } + return new Config(sb.toString(), props); + } +} diff --git a/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/role/config/LdapMapRoleMapperConfigTest.java b/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/role/config/LdapMapRoleMapperConfigTest.java new file mode 100644 index 0000000000..8ef228b0c1 --- /dev/null +++ b/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/role/config/LdapMapRoleMapperConfigTest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.role.config; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.models.map.storage.ldap.Config; + +public class LdapMapRoleMapperConfigTest { + + @Test + public void shouldEscapeClientNameForPlaceholder() { + Config config = new Config(); + config.put(LdapMapRoleMapperConfig.CLIENT_ROLES_DN, "ou={0},dc=keycloak,dc=org"); + LdapMapRoleMapperConfig sut = new LdapMapRoleMapperConfig(config); + + Assert.assertEquals("ou=myclient,dc=keycloak,dc=org", + sut.getRolesDn(true, "myclient")); + Assert.assertEquals("ou=\\ me\\=co\\\\ol\\, val\\=V\u00E9ronique,dc=keycloak,dc=org", + sut.getRolesDn(true, " me=co\\ol, val=V\u00E9ronique")); + } + +} \ No newline at end of file diff --git a/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/store/LdapMapEscapeStrategyTest.java b/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/store/LdapMapEscapeStrategyTest.java new file mode 100644 index 0000000000..dd1fc4179e --- /dev/null +++ b/model/map-ldap/src/test/java/org/keycloak/models/map/storage/ldap/store/LdapMapEscapeStrategyTest.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.store; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Test escaping of characters. + * + * Test cases extracted from https://docs.oracle.com/cd/E29127_01/doc.111170/e28969/ds-ldif-search-filters.htm#gdxoy + */ +public class LdapMapEscapeStrategyTest { + + @Test + public void shouldEscapeUtf8CharactersForDefaultStrategy() { + Assert.assertEquals("V\\c3\\a9ronique", LdapMapEscapeStrategy.DEFAULT.escape("V\u00E9ronique")); + } + + @Test + public void shouldEscapeLdapQueryCharactersCharactersForDefaultStrategy() { + Assert.assertEquals("\\28\\29\\2a\\5c", LdapMapEscapeStrategy.DEFAULT.escape("()*\\")); + } + +} \ No newline at end of file diff --git a/model/map/src/main/java/org/keycloak/models/map/common/DeepCloner.java b/model/map/src/main/java/org/keycloak/models/map/common/DeepCloner.java index 6560f3e137..6697c84627 100644 --- a/model/map/src/main/java/org/keycloak/models/map/common/DeepCloner.java +++ b/model/map/src/main/java/org/keycloak/models/map/common/DeepCloner.java @@ -169,7 +169,7 @@ public class DeepCloner { * * @param Class or interface that would be instantiated by the given methods * @param clazz Class or interface that would be instantiated by the given methods - * @param constructor Function that creates a new instance of class {@code V}. + * @param delegateCreator Function that creates a new instance of class {@code V}. * If {@code null}, such a single-parameter constructor is not available. * @return This builder. */ @@ -185,7 +185,7 @@ public class DeepCloner { * * @param Class or interface that would be instantiated by the given methods * @param clazz Class or interface that would be instantiated by the given methods - * @param constructor Function that creates a new instance of class {@code V}. + * @param delegateCreator Function that creates a new instance of class {@code V}. * If {@code null}, such a single-parameter constructor is not available. * @return This builder. */ @@ -376,7 +376,7 @@ public class DeepCloner { /** * Returns a class type of an instance that would be instantiated by {@link #newInstance(java.lang.Class)} method. * @param Type (class or a {@code @Root} interface) to create a new instance - * @param clazz Type (class or a {@code @Root} interface) to create a new instance + * @param valueType Type (class or a {@code @Root} interface) to create a new instance * @return See description */ @SuppressWarnings("unchecked") diff --git a/model/map/src/main/java/org/keycloak/models/map/role/MapRoleProvider.java b/model/map/src/main/java/org/keycloak/models/map/role/MapRoleProvider.java index 3d376bb5a9..734036abf2 100644 --- a/model/map/src/main/java/org/keycloak/models/map/role/MapRoleProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/role/MapRoleProvider.java @@ -71,7 +71,7 @@ public class MapRoleProvider implements RoleProvider { entity.setRealmId(realm.getId()); entity.setName(name); entity.setClientRole(false); - if (tx.read(entity.getId()) != null) { + if (entity.getId() != null && tx.read(entity.getId()) != null) { throw new ModelDuplicateException("Role exists: " + id); } entity = tx.create(entity); @@ -129,7 +129,7 @@ public class MapRoleProvider implements RoleProvider { entity.setName(name); entity.setClientRole(true); entity.setClientId(client.getId()); - if (tx.read(entity.getId()) != null) { + if (entity.getId() != null && tx.read(entity.getId()) != null) { throw new ModelDuplicateException("Role exists: " + id); } entity = tx.create(entity); @@ -244,7 +244,8 @@ public class MapRoleProvider implements RoleProvider { MapRoleEntity entity = tx.read(id); String realmId = realm.getId(); - return (entity == null || ! Objects.equals(realmId, entity.getRealmId())) + // when a store doesn't store information about all realms, it doesn't have the information about + return (entity == null || (entity.getRealmId() != null && !Objects.equals(realmId, entity.getRealmId()))) ? null : entityToAdapterFunc(realm).apply(entity); } diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/MapKeycloakTransaction.java b/model/map/src/main/java/org/keycloak/models/map/storage/MapKeycloakTransaction.java index cd7a5de428..d7de3a404d 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/MapKeycloakTransaction.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/MapKeycloakTransaction.java @@ -29,8 +29,12 @@ public interface MapKeycloakTransaction extends Key * Updates to the returned instances of {@code V} would be visible in the current transaction * and will propagate into the underlying store upon commit. * + * The ID of the entity passed in the parameter might change to a different value in the returned value + * if the underlying storage decided this was necessary. + * If the ID of the entity was null before, it will be set on the returned value. + * * @param value the value - * @return Entity representing the {@code value} in the store. It may or may not be the same instance as {@code value} + * @return Entity representing the {@code value} in the store. It may or may not be the same instance as {@code value}. */ V create(V value); @@ -47,7 +51,7 @@ public interface MapKeycloakTransaction extends Key /** * Returns a stream of values from underlying storage that are updated based on the current transaction changes; * i.e. the result contains updates and excludes of records that have been created, updated or deleted in this - * transaction by methods {@link MapKeycloakTransaction#create}, {@link MapKeycloakTransaction#update}, + * transaction by methods {@link MapKeycloakTransaction#create}, {@link MapKeycloakTransaction#create}, * {@link MapKeycloakTransaction#delete}, etc. *

* Updates to the returned instances of {@code V} would be visible in the current transaction @@ -79,8 +83,6 @@ public interface MapKeycloakTransaction extends Key /** * Instructs this transaction to remove values (identified by {@code mcb} filter) from the underlying store on commit. * - * @param artificialKey key to record the transaction with, must be a key that does not exist in this transaction to - * prevent collisions with other operations in this transaction * @param queryParameters parameters for the query like firstResult, maxResult, requested ordering, etc. * @return number of removed objects (might return {@code -1} if not supported) */ diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapModelCriteriaBuilder.java b/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapModelCriteriaBuilder.java index ca7d047aee..ce816adb22 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapModelCriteriaBuilder.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapModelCriteriaBuilder.java @@ -35,12 +35,12 @@ import java.util.stream.Collectors; public class MapModelCriteriaBuilder implements ModelCriteriaBuilder> { @FunctionalInterface - public static interface UpdatePredicatesFunc { + public interface UpdatePredicatesFunc { MapModelCriteriaBuilder apply(MapModelCriteriaBuilder builder, Operator op, Object[] params); } - private static final Predicate ALWAYS_TRUE = (e) -> true; - private static final Predicate ALWAYS_FALSE = (e) -> false; + protected static final Predicate ALWAYS_TRUE = (e) -> true; + protected static final Predicate ALWAYS_FALSE = (e) -> false; private final Predicate keyFilter; private final Predicate entityFilter; private final Map, UpdatePredicatesFunc> fieldPredicates; @@ -50,7 +50,7 @@ public class MapModelCriteriaBuilder implements this(keyConvertor, fieldPredicates, ALWAYS_TRUE, ALWAYS_TRUE); } - private MapModelCriteriaBuilder(StringKeyConvertor keyConvertor, Map, UpdatePredicatesFunc> fieldPredicates, Predicate indexReadFilter, Predicate sequentialReadFilter) { + protected MapModelCriteriaBuilder(StringKeyConvertor keyConvertor, Map, UpdatePredicatesFunc> fieldPredicates, Predicate indexReadFilter, Predicate sequentialReadFilter) { this.keyConvertor = keyConvertor; this.fieldPredicates = fieldPredicates; this.keyFilter = indexReadFilter; @@ -73,7 +73,7 @@ public class MapModelCriteriaBuilder implements public final MapModelCriteriaBuilder and(MapModelCriteriaBuilder... builders) { Predicate resIndexFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getKeyFilter).reduce(keyFilter, Predicate::and); Predicate resEntityFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getEntityFilter).reduce(entityFilter, Predicate::and); - return new MapModelCriteriaBuilder<>(keyConvertor, fieldPredicates, resIndexFilter, resEntityFilter); + return instantiateNewInstance(keyConvertor, fieldPredicates, resIndexFilter, resEntityFilter); } @SafeVarargs @@ -82,7 +82,7 @@ public class MapModelCriteriaBuilder implements public final MapModelCriteriaBuilder or(MapModelCriteriaBuilder... builders) { Predicate resIndexFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getKeyFilter).reduce(ALWAYS_FALSE, Predicate::or); Predicate resEntityFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getEntityFilter).reduce(ALWAYS_FALSE, Predicate::or); - return new MapModelCriteriaBuilder<>( + return instantiateNewInstance( keyConvertor, fieldPredicates, v -> keyFilter.test(v) && resIndexFilter.test(v), @@ -90,14 +90,12 @@ public class MapModelCriteriaBuilder implements ); } - @SuppressWarnings("unchecked") @Override public MapModelCriteriaBuilder not(MapModelCriteriaBuilder builder) { - MapModelCriteriaBuilder b = (MapModelCriteriaBuilder) builder; Predicate resIndexFilter = builder.getKeyFilter() == ALWAYS_TRUE ? ALWAYS_TRUE : builder.getKeyFilter().negate(); Predicate resEntityFilter = builder.getEntityFilter() == ALWAYS_TRUE ? ALWAYS_TRUE : builder.getEntityFilter().negate(); - return new MapModelCriteriaBuilder<>( + return instantiateNewInstance( keyConvertor, fieldPredicates, v -> keyFilter.test(v) && resIndexFilter.test(v), @@ -125,7 +123,7 @@ public class MapModelCriteriaBuilder implements case EXISTS: case NOT_EXISTS: case IN: - return new MapModelCriteriaBuilder<>(keyConvertor, fieldPredicates, this.keyFilter.and(CriteriaOperator.predicateFor(op, convertedValues)), this.entityFilter); + return instantiateNewInstance(keyConvertor, fieldPredicates, this.keyFilter.and(CriteriaOperator.predicateFor(op, convertedValues)), this.entityFilter); default: throw new AssertionError("Invalid operator: " + op); } @@ -167,6 +165,16 @@ public class MapModelCriteriaBuilder implements final Predicate p = v -> valueComparator.test(getter.apply(v)); resEntityFilter = p.and(entityFilter); } - return new MapModelCriteriaBuilder<>(keyConvertor, fieldPredicates, this.keyFilter, resEntityFilter); + return instantiateNewInstance(keyConvertor, fieldPredicates, this.keyFilter, resEntityFilter); + } + + /** + * Return a new instance for nodes in this criteria tree. + * + * Subclasses can override this method to instantiate a new instance of their subclass. This allows this class to + * be extendable. + */ + protected MapModelCriteriaBuilder instantiateNewInstance(StringKeyConvertor keyConvertor, Map, UpdatePredicatesFunc> fieldPredicates, Predicate indexReadFilter, Predicate sequentialReadFilter) { + return new MapModelCriteriaBuilder<>(keyConvertor, fieldPredicates, indexReadFilter, sequentialReadFilter); } } diff --git a/model/pom.xml b/model/pom.xml index daf42c3f72..cf83b79195 100755 --- a/model/pom.xml +++ b/model/pom.xml @@ -37,5 +37,6 @@ map build-processor map-hot-rod + map-ldap diff --git a/pom.xml b/pom.xml index 675a2ea66f..7c9f3e1a0c 100644 --- a/pom.xml +++ b/pom.xml @@ -1274,6 +1274,11 @@ keycloak-model-map-jpa ${project.version} + + org.keycloak + keycloak-model-map-ldap + ${project.version} + org.keycloak keycloak-model-infinispan diff --git a/testsuite/model/pom.xml b/testsuite/model/pom.xml index b3764d3d46..47a48c6d5c 100644 --- a/testsuite/model/pom.xml +++ b/testsuite/model/pom.xml @@ -96,6 +96,10 @@ org.keycloak keycloak-model-map-hot-rod + + org.keycloak + keycloak-model-map-ldap + org.infinispan infinispan-server-core @@ -303,6 +307,15 @@ Jpa,Map,HotRodMapStorage + + + map-ldap + + enabled + Jpa,Map,LdapMapStorage + + + diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/LdapMapStorage.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/LdapMapStorage.java new file mode 100644 index 0000000000..df6943f242 --- /dev/null +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/LdapMapStorage.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 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.model.parameters; + +import com.google.common.collect.ImmutableSet; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.keycloak.authorization.store.StoreFactorySpi; +import org.keycloak.models.DeploymentStateSpi; +import org.keycloak.models.UserLoginFailureSpi; +import org.keycloak.models.UserSessionSpi; +import org.keycloak.models.map.storage.MapStorageSpi; +import org.keycloak.models.map.storage.chm.ConcurrentHashMapStorageProviderFactory; +import org.keycloak.models.map.storage.ldap.LdapMapStorageProviderFactory; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; +import org.keycloak.testsuite.model.Config; +import org.keycloak.testsuite.model.KeycloakModelParameters; +import org.keycloak.testsuite.util.LDAPRule; +import org.keycloak.util.ldap.LDAPEmbeddedServer; + +import java.util.Set; + +/** + * @author Alexander Schwartz + */ +public class LdapMapStorage extends KeycloakModelParameters { + + static final Set> ALLOWED_SPIS = ImmutableSet.>builder() + .build(); + + static final Set> ALLOWED_FACTORIES = ImmutableSet.>builder() + .add(ConcurrentHashMapStorageProviderFactory.class) + .add(LdapMapStorageProviderFactory.class) + .build(); + + private final LDAPRule ldapRule = new LDAPRule(); + + public LdapMapStorage() { + super(ALLOWED_SPIS, ALLOWED_FACTORIES); + } + + @Override + public void updateConfig(Config cf) { + cf.spi(MapStorageSpi.NAME) + .provider(ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .config("dir", "${project.build.directory:target}"); + + cf.spi(MapStorageSpi.NAME) + .provider(LdapMapStorageProviderFactory.PROVIDER_ID) + .config("vendor", "other") + .config("usernameLDAPAttribute", "uid") + .config("rdnLDAPAttribute", "uid") + .config("uuidLDAPAttribute", "entryUUID") + .config("userObjectClasses", "inetOrgPerson, organizationalPerson") + .config("connectionUrl", "ldap://localhost:10389") + .config("usersDn", "ou=People,dc=keycloak,dc=org") + .config("bindDn", "uid=admin,ou=system") + .config("bindCredential", "secret") + .config("roles.realm.dn", "ou=RealmRoles,dc=keycloak,dc=org") + .config("roles.client.dn", "ou={0},dc=keycloak,dc=org") + .config("roles.common.dn", "dc=keycloak,dc=org") // this is the top DN that finds both client and realm roles + .config("membership.ldap.attribute", "member") + .config("role.name.ldap.attribute", "cn") + .config("role.object.classes", "groupOfNames") + .config("role.attributes", "ou") + .config("mode", "LDAP_ONLY") + .config("use.realm.roles.mapping", "true"); + + cf.spi("client").config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi("clientScope").config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi("group").config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi("realm").config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi("role").config("map.storage.provider", LdapMapStorageProviderFactory.PROVIDER_ID) + .spi(DeploymentStateSpi.NAME).config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi(StoreFactorySpi.NAME).config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi("user").config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi(UserSessionSpi.NAME).config("map.storage-user-sessions.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi(UserSessionSpi.NAME).config("map.storage-client-sessions.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi(UserLoginFailureSpi.NAME).config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi("authorizationPersister").config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) + .spi("authenticationSessions").config("map.storage.provider", ConcurrentHashMapStorageProviderFactory.PROVIDER_ID); + + } + + static { + System.setProperty(LDAPEmbeddedServer.PROPERTY_ENABLE_SSL, "false"); + } + + @Override + public Statement classRule(Statement base, Description description) { + return ldapRule.apply(base, description); + } + +} diff --git a/testsuite/utils/pom.xml b/testsuite/utils/pom.xml index a3a15e9388..31c9a390b2 100755 --- a/testsuite/utils/pom.xml +++ b/testsuite/utils/pom.xml @@ -286,7 +286,6 @@ keycloak.profile.feature.map_storageenabled - keycloak.mapStorage.providerconcurrenthashmap keycloak.realm.providermap keycloak.client.providermap keycloak.clientScope.providermap diff --git a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json index 19dfb98500..4f4968db1b 100755 --- a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json +++ b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json @@ -117,6 +117,26 @@ "driver": "org.postgresql.Driver", "driverDialect": "org.keycloak.models.map.storage.jpa.hibernate.dialect.JsonbPostgreSQL95Dialect", "showSql": "${keycloak.map.storage.connectionsJpa,showSql:false}" + }, + "ldap-map-storage": { + "vendor": "other", + "usernameLDAPAttribute": "uid", + "rdnLDAPAttribute": "uid", + "uuidLDAPAttribute": "entryUUID", + "userObjectClasses": "inetOrgPerson, organizationalPerson", + "connectionUrl": "${keycloak.map.storage.ldap.connectionUrl:}", + "usersDn": "ou=People,dc=keycloak,dc=org", + "bindDn": "${keycloak.map.storage.ldap.bindDn:}", + "bindCredential": "${keycloak.map.storage.ldap.bindCredential:}", + "roles.realm.dn": "ou=RealmRoles,dc=keycloak,dc=org", + "roles.common.dn": "dc=keycloak,dc=org", + "roles.client.dn": "ou={0},dc=keycloak,dc=org", + "membership.ldap.attribute": "member", + "role.name.ldap.attribute": "cn", + "role.object.classes": "groupOfNames", + "role.attributes": "ou", + "mode": "LDAP_ONLY", + "use.realm.roles.mapping": "true" } }, @@ -168,6 +188,13 @@ } }, + "realmCache": { + "provider": "${keycloak.realm.cache.provider:default}", + "default" : { + "enabled": "${keycloak.realm.cache.provider.enabled:true}" + } + }, + "connectionsInfinispan": { "default": { "jgroupsUdpMcastAddr": "${keycloak.connectionsInfinispan.jgroupsUdpMcastAddr:234.56.78.90}",