LDAP Map storage support to support read/write for roles

Closes #9929
This commit is contained in:
Alexander Schwartz 2022-02-02 10:30:47 +01:00 committed by Marek Posolda
parent 93bba8e338
commit 3c3f003a38
38 changed files with 4904 additions and 22 deletions

View file

@ -48,6 +48,10 @@
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-model-map-jpa</artifactId> <artifactId>keycloak-model-map-jpa</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-map-ldap</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-model-infinispan</artifactId> <artifactId>keycloak-model-infinispan</artifactId>

42
model/map-ldap/pom.xml Normal file
View file

@ -0,0 +1,42 @@
<?xml version="1.0"?>
<!--
~ 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-model-pom</artifactId>
<groupId>org.keycloak</groupId>
<version>18.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-model-map-ldap</artifactId>
<name>Keycloak Model Map LDAP</name>
<description/>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-map</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View file

@ -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<RE, E extends AbstractEntity & UpdatableEntity, M> implements MapKeycloakTransaction<E, M> {
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<MapTaskWithValue> tasksOnRollback = new LinkedList<>();
protected final LinkedList<MapTaskWithValue> tasksOnCommit = new LinkedList<>();
protected final Map<String, RE> entities = new HashMap<>();
public long getCount(QueryParameters<M> queryParameters) {
return read(queryParameters).count();
}
public long delete(QueryParameters<M> 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;
}
}

View file

@ -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 <V extends AbstractEntity, M> MapStorage<V, M> getStorage(Class<M> modelType, Flag... flags) {
return session -> {
MapKeycloakTransaction<V, M> sessionTx = session.getAttribute(sessionTxPrefix + modelType.hashCode(), MapKeycloakTransaction.class);
if (sessionTx == null) {
sessionTx = factory.createTransaction(session, modelType);
session.setAttribute(sessionTxPrefix + modelType.hashCode(), sessionTx);
}
return sessionTx;
};
}
}

View file

@ -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<MapStorageProvider>,
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<Class<?>, LdapRoleMapKeycloakTransaction.LdapRoleMapKeycloakTransactionFunction<KeycloakSession, Config.Scope, MapKeycloakTransaction>> 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 <M, V extends AbstractEntity> MapKeycloakTransaction<V, M> createTransaction(KeycloakSession session, Class<M> 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() {
}
}

View file

@ -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 <E> Entity
* @param <M> Model
* @param <Self> specific implementation of this class
*/
public abstract class LdapModelCriteriaBuilder<E, M, Self extends LdapModelCriteriaBuilder<E, M, Self>> implements ModelCriteriaBuilder<M, Self> {
private final Function<Supplier<StringBuilder>, Self> instantiator;
private Supplier<StringBuilder> predicateFunc = null;
public LdapModelCriteriaBuilder(Function<Supplier<StringBuilder>, 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<StringBuilder> getPredicateFunc() {
return predicateFunc;
}
public LdapModelCriteriaBuilder(Function<Supplier<StringBuilder>, Self> instantiator,
Supplier<StringBuilder> 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;
}
}

View file

@ -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<K, V extends AbstractEntity, M> extends MapModelCriteriaBuilder<K, V, M> {
private final Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates;
private final StringKeyConvertor<K> keyConvertor;
private final SearchableModelField<? super M> modelFieldThatShouldCompareToTrueForEqual;
public MapModelCriteriaBuilderAssumingEqualForField(StringKeyConvertor<K> keyConvertor, Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates, SearchableModelField<? super M> modelFieldThatShouldCompareToTrueForEqual) {
this(keyConvertor, fieldPredicates, ALWAYS_TRUE, ALWAYS_TRUE, modelFieldThatShouldCompareToTrueForEqual);
}
protected MapModelCriteriaBuilderAssumingEqualForField(StringKeyConvertor<K> keyConvertor, Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates, Predicate<? super K> indexReadFilter, Predicate<? super V> sequentialReadFilter, SearchableModelField<? super M> modelFieldThatShouldCompareToTrueForEqual) {
super(keyConvertor, fieldPredicates, indexReadFilter, sequentialReadFilter);
this.keyConvertor = keyConvertor;
this.modelFieldThatShouldCompareToTrueForEqual = modelFieldThatShouldCompareToTrueForEqual;
this.fieldPredicates = fieldPredicates;
}
@Override
public MapModelCriteriaBuilder<K, V, M> compare(SearchableModelField<? super M> 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<K, V, M> instantiateNewInstance(StringKeyConvertor<K> keyConvertor, Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates, Predicate<? super K> indexReadFilter, Predicate<? super V> sequentialReadFilter) {
return new MapModelCriteriaBuilderAssumingEqualForField<>(keyConvertor, fieldPredicates, indexReadFilter, sequentialReadFilter, modelFieldThatShouldCompareToTrueForEqual);
}
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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<String> getConfigValues(String str) {
String[] objClasses = str.split(",");
Set<String> trimmed = new HashSet<>();
for (String objectClass : objClasses) {
objectClass = objectClass.trim();
if (objectClass.length() > 0) {
trimmed.add(objectClass);
}
}
return trimmed;
}
public abstract String getLDAPGroupNameLdapAttribute();
}

View file

@ -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<String, String> config;
public LdapMapConfig(Config.Scope config) {
this.config = hm(config);
}
private static MultivaluedHashMap<String, String> hm(Config.Scope config) {
return new MultivaluedHashMap<String, String>() {
@Override
public String getFirst(String key) {
return config.get(key);
}
};
}
// from: RoleMapperConfig
public Collection<String> 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<String> getConfigValues(String str) {
String[] objClasses = str.split(",");
Set<String> trimmed = new HashSet<>();
for (String objectClass : objClasses) {
objectClass = objectClass.trim();
if (objectClass.length() > 0) {
trimmed.add(objectClass);
}
}
return trimmed;
}
private final Set<String> 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<String> 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<String> 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<String> getBinaryAttributeNames() {
return binaryAttributeNames;
}
public boolean isEdirectory() {
return LDAPConstants.VENDOR_NOVELL_EDIRECTORY.equalsIgnoreCase(getVendor());
}
@Override
public String toString() {
MultivaluedHashMap<String, String> copy = new MultivaluedHashMap<>(config);
copy.remove(LDAPConstants.BIND_CREDENTIAL);
return copy + ", binaryAttributes: " + binaryAttributeNames;
}
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class LdapMapDn {
private static final Pattern DN_PATTERN = Pattern.compile("(?<!\\\\),");
private static final Pattern ENTRY_PATTERN = Pattern.compile("(?<!\\\\)\\+");
private static final Pattern SUB_ENTRY_PATTERN = Pattern.compile("(?<!\\\\)=");
private final Deque<RDN> entries;
private LdapMapDn() {
this.entries = new LinkedList<>();
}
private LdapMapDn(Deque<RDN> 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<RDN> 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<RDN> parentDnEntries = new LinkedList<>(entries);
parentDnEntries.remove();
return new LdapMapDn(parentDnEntries);
}
public boolean isDescendantOf(LdapMapDn expectedParentDn) {
int parentEntriesCount = expectedParentDn.entries.size();
Deque<RDN> 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<SubEntry> 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<String> 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;
}
}
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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<String> rdnAttributeNames = new LinkedList<>();
private final List<String> objectClasses = new LinkedList<>();
// NOTE: names of read-only attributes are lower-cased to avoid case sensitivity issues
private final List<String> readOnlyAttributeNames = new LinkedList<>();
private final Map<String, Set<String>> attributes = new HashMap<>();
// Copy of "attributes" containing lower-cased keys
private final Map<String, Set<String>> lowerCasedAttributes = new HashMap<>();
// range attributes are always read from 0 to max so just saving the top value
private final Map<String, Integer> 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<String> getObjectClasses() {
return objectClasses;
}
public void setObjectClasses(Collection<String> objectClasses) {
this.objectClasses.clear();
this.objectClasses.addAll(objectClasses);
}
public List<String> getReadOnlyAttributeNames() {
return readOnlyAttributeNames;
}
public void addReadOnlyAttributeName(String readOnlyAttribute) {
readOnlyAttributeNames.add(readOnlyAttribute.toLowerCase());
}
public void removeReadOnlyAttributeName(String readOnlyAttribute) {
readOnlyAttributeNames.remove(readOnlyAttribute.toLowerCase());
}
public List<String> 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<String> 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<String> asSet = new LinkedHashSet<>();
asSet.add(attributeValue);
setAttribute(attributeName, asSet);
}
public void setAttribute(String attributeName, Set<String> attributeValue) {
attributes.put(attributeName, attributeValue);
lowerCasedAttributes.put(attributeName.toLowerCase(), attributeValue);
}
// Case-insensitive
public String getAttributeAsString(String name) {
Set<String> 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<String> getAttributeAsSet(String name) {
Set<String> 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<String> 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<String, Set<String>> 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 + " ]";
}
}

View file

@ -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<String> 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<String> returningReadOnlyLdapAttributes = new LinkedHashSet<>();
private final Set<String> objectClasses = new LinkedHashSet<>();
private final List<ComponentModel> mappers = new ArrayList<>();
private int searchScope = SearchControls.SUBTREE_SCOPE;
public void setSearchDn(String searchDn) {
this.searchDn = searchDn;
}
public void addObjectClasses(Collection<String> 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<ComponentModel> mappers) {
this.mappers.addAll(mappers);
return this;
}
public void setSearchScope(int searchScope) {
this.searchScope = searchScope;
}
public String getSearchDn() {
return this.searchDn;
}
public Set<String> getObjectClasses() {
return unmodifiableSet(this.objectClasses);
}
public Set<String> getReturningLdapAttributes() {
return unmodifiableSet(this.returningLdapAttributes);
}
public Set<String> getReturningReadOnlyLdapAttributes() {
return unmodifiableSet(this.returningReadOnlyLdapAttributes);
}
public List<ComponentModel> 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;
}
}

View file

@ -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<LdapMapRoleEntityFieldDelegate, MapRoleEntity, RoleModel> {
private final KeycloakSession session;
private final StringKeyConvertor<String> keyConverter = new StringKeyConvertor.StringKey();
private final Set<String> 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<A, B, R> {
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<String, String> 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<String, LdapMapRoleEntityFieldDelegate> 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<String> 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<LdapMapObject> ldapObjects = identityStore.fetchQueryResults(ldapQuery);
if (ldapObjects.size() == 1) {
dns.put(dn, ldapObjects.get(0).getId());
return ldapObjects.get(0).getId();
}
return null;
}
private MapModelCriteriaBuilder<String, MapRoleEntity, RoleModel> 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<MapRoleEntity> read(QueryParameters<RoleModel> 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<MapRoleEntity> ldapStream;
MapModelCriteriaBuilder<String,MapRoleEntity,RoleModel> mapMcb = queryParameters.getModelCriteriaBuilder().flashToModelCriteriaBuilder(createCriteriaBuilderMap());
Stream<LdapMapRoleEntityFieldDelegate> 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<LdapMapObject> 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<Boolean, String> 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<Boolean, String> 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<String> 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<MapTaskWithValue> iterator = tasksOnRollback.descendingIterator();
while (iterator.hasNext()) {
iterator.next().execute();
}
}
protected LdapRoleModelCriteriaBuilder createLdapModelCriteriaBuilder() {
return new LdapRoleModelCriteriaBuilder(roleMapperConfig);
}
}

View file

@ -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<LdapRoleEntity, RoleModel, LdapRoleModelCriteriaBuilder> {
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<StringBuilder> predicateFunc) {
super(pf -> new LdapRoleModelCriteriaBuilder(roleMapperConfig, pf), predicateFunc);
this.roleMapperConfig = roleMapperConfig;
}
@Override
public LdapRoleModelCriteriaBuilder compare(SearchableModelField<? super RoleModel> 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<? super RoleModel> 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;
}
}

View file

@ -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<String, String> getConfig() {
return new MultivaluedHashMap<String, String>() {
@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<String> 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;
}
}

View file

@ -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<MapRoleEntity> 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();
}
}

View file

@ -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<MapRoleEntity> {
private final LdapMapObject ldapMapObject;
private final LdapMapRoleMapperConfig roleMapperConfig;
private final LdapRoleMapKeycloakTransaction transaction;
private final String clientId;
private static final EnumMap<MapRoleEntityFields, BiConsumer<LdapRoleEntity, Object>> 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<String, List<String>>) v));
//noinspection unchecked
SETTERS.put(MapRoleEntityFields.COMPOSITE_ROLES, (e, v) -> e.setCompositeRoles((Set<String>) v));
SETTERS.put(MapRoleEntityFields.NAME, (e, v) -> e.setName((String) v));
}
private static final EnumMap<MapRoleEntityFields, Function<LdapRoleEntity, Object>> 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<MapRoleEntityFields, BiConsumer<LdapRoleEntity, Object>> ADDERS = new EnumMap<>(MapRoleEntityFields.class);
static {
ADDERS.put(MapRoleEntityFields.COMPOSITE_ROLES, (e, v) -> e.addCompositeRole((String) v));
}
private static final EnumMap<MapRoleEntityFields, BiFunction<LdapRoleEntity, Object, Object>> 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<String, List<String>> getAttributes() {
Map<String, List<String>> result = new HashMap<>();
for (String roleAttribute : roleMapperConfig.getRoleAttributes()) {
Set<String> attrs = ldapMapObject.getAttributeAsSet(roleAttribute);
if (attrs != null) {
result.put(roleAttribute, new ArrayList<>(attrs));
}
}
return result;
}
public void setAttributes(Map<String, List<String>> 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<String> 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<String> 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<String> getCompositeRoles() {
Set<String> members = ldapMapObject.getAttributeAsSet(roleMapperConfig.getMembershipLdapAttribute());
if (members == null) {
members = new HashSet<>();
}
HashSet<String> 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<String> compositeRoles) {
HashSet<String> translatedCompositeRoles = new HashSet<>();
if (compositeRoles != null) {
for (String compositeRole : compositeRoles) {
LdapRoleEntity ldapRole = transaction.readLdap(compositeRole);
translatedCompositeRoles.add(ldapRole.getLdapMapObject().getDn().toString());
}
}
Set<String> 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<String> 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<String> 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 <T, EF extends Enum<? extends EntityField<MapRoleEntity>> & EntityField<MapRoleEntity>> void set(EF field, T value) {
BiConsumer<LdapRoleEntity, Object> consumer = SETTERS.get(field);
if (consumer == null) {
throw new ModelException("unsupported field for setters " + field);
}
consumer.accept(this, value);
}
@Override
public <T, EF extends Enum<? extends EntityField<MapRoleEntity>> & EntityField<MapRoleEntity>> void collectionAdd(EF field, T value) {
BiConsumer<LdapRoleEntity, Object> consumer = ADDERS.get(field);
if (consumer == null) {
throw new ModelException("unsupported field for setters " + field);
}
consumer.accept(this, value);
}
@Override
public <T, EF extends Enum<? extends EntityField<MapRoleEntity>> & EntityField<MapRoleEntity>> Object collectionRemove(EF field, T value) {
BiFunction<LdapRoleEntity, Object, Object> consumer = REMOVERS.get(field);
if (consumer == null) {
throw new ModelException("unsupported field for setters " + field);
}
return consumer.apply(this, value);
}
@Override
public <EF extends Enum<? extends EntityField<MapRoleEntity>> & EntityField<MapRoleEntity>> Object get(EF field) {
Function<LdapRoleEntity, Object> consumer = GETTERS.get(field);
if (consumer == null) {
throw new ModelException("unsupported field for getters " + field);
}
return consumer.apply(this);
}
}

View file

@ -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<CharBuffer> get() {
return Optional.empty();
}
@Override
public Optional<char[]> 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<Object, Object> 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<Object, Object> getConnectionProperties(LdapMapConfig ldapMapConfig) {
Hashtable<Object, Object> 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<Object, Object> 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<Object, Object> getNonAuthConnectionProperties(LdapMapConfig ldapMapConfig) {
HashMap<String, Object> 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);
}
}
}
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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));
}
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Silva</a>
*/
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<Attribute> 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<String> toUpdateKeys = firstRdn.getAllKeys();
toUpdateKeys.retainAll(ldapObject.getRdnAttributeNames());
List<String> toRemoveKeys = firstRdn.getAllKeys();
toRemoveKeys.removeAll(ldapObject.getRdnAttributeNames());
List<String> 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<LdapMapObject> fetchQueryResults(LdapMapQuery identityQuery) {
List<LdapMapObject> results = new ArrayList<>();
StringBuilder filter = null;
try {
String baseDN = identityQuery.getSearchDn();
filter = createIdentityTypeSearchFilter(identityQuery);
List<SearchResult> 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<LDAPCapabilityRepresentation> queryServerCapabilities() {
Set<LDAPCapabilityRepresentation> result = new LinkedHashSet<>();
try {
List<String> attrs = new ArrayList<>();
attrs.add("supportedControl");
attrs.add("supportedExtension");
attrs.add("supportedFeatures");
List<SearchResult> 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<String> 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<String> readOnlyAttrNames = ldapQuery.getReturningReadOnlyLdapAttributes();
Set<String> 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<? extends Attribute> 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<String> 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<String> rdnAttrNamesLowerCased = ldapObject.getRdnAttributeNames().stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
for (Map.Entry<String, Set<String>> attrEntry : ldapObject.getAttributes().entrySet()) {
String attrName = attrEntry.getKey();
Set<String> 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<String> 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<String> 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<SearchResult> 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() + "].");
}
}
}

View file

@ -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;
}
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public interface LdapMapOperationDecorator {
<R> void beforeLDAPOperation(LdapContext ldapContext, LdapMapOperationManager.LdapOperation<R> ldapOperation) throws NamingException;
}

View file

@ -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;
/**
* <p>This class provides a set of operations to manage LDAP trees.</p>
*
* @author Anil Saldhana
* @author <a href="mailto:psilva@redhat.com">Pedro Silva</a>
*/
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;
}
/**
* <p>
* Modifies the given {@link Attribute} instance using the given DN. This method performs a REPLACE_ATTRIBUTE
* operation.
* </p>
*
*/
public void modifyAttribute(String dn, Attribute attribute) {
ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attribute)};
modifyAttributes(dn, mods, null);
}
/**
* <p>
* Modifies the given {@link Attribute} instances using the given DN. This method performs a REPLACE_ATTRIBUTE
* operation.
* </p>
*
*/
public void modifyAttributes(String dn, NamingEnumeration<Attribute> attributes) {
try {
List<ModificationItem> 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);
}
}
/**
* <p>
* Removes the given {@link Attribute} instance using the given DN. This method performs a REMOVE_ATTRIBUTE
* operation.
* </p>
*
*/
public void removeAttribute(String dn, Attribute attribute) {
ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.REMOVE_ATTRIBUTE, attribute)};
modifyAttributes(dn, mods, null);
}
/**
* <p>
* Adds the given {@link Attribute} instance using the given DN. This method performs a ADD_ATTRIBUTE operation.
* </p>
*
*/
public void addAttribute(String dn, Attribute attribute) {
ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.ADD_ATTRIBUTE, attribute)};
modifyAttributes(dn, mods, null);
}
/**
* <p>
* Removes the object from the LDAP tree
* </p>
*/
public void removeEntry(final String entryDn) {
try {
execute(new LdapOperation<SearchResult>() {
@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<String>() {
@Override
public String execute(LdapContext context) throws NamingException {
String dn = newDn;
// Max 5 attempts for now
int max = 5;
for (int i=0 ; i<max ; i++) {
try {
context.rename(new LdapName(oldDn), new LdapName(dn));
return dn;
} catch (NameAlreadyBoundException ex) {
if (!fallback) {
throw ex;
} else {
String failedDn = dn;
dn = findNextDNForFallback(newDn, i);
logger.warnf("Failed to rename DN [%s] to [%s]. Will try to fallback to DN [%s]", oldDn, failedDn, dn);
}
}
}
throw new ModelException("Could not rename entry from DN [" + oldDn + "] to new DN [" + newDn + "]. All fallbacks failed");
}
@Override
public String toString() {
return "LdapOperation: renameEntry\n" +
" oldDn: " + oldDn + "\n" +
" newDn: " + newDn;
}
});
} catch (NamingException e) {
throw new ModelException("Could not rename entry from DN [" + oldDn + "] to new DN [" + newDn + "]", e);
}
}
private String findNextDNForFallback(String newDn, int counter) {
LdapMapDn dn = LdapMapDn.fromString(newDn);
LdapMapDn.RDN firstRdn = dn.getFirstRdn();
String rdnAttrName = firstRdn.getAllKeys().get(0);
String rdnAttrVal = firstRdn.getAttrValue(rdnAttrName);
LdapMapDn parentDn = dn.getParentDn();
parentDn.addFirst(rdnAttrName, rdnAttrVal + counter);
return parentDn.toString();
}
public List<SearchResult> search(final String baseDN, final String filter, Collection<String> returningAttributes, int searchScope) throws NamingException {
final List<SearchResult> result = new ArrayList<>();
final SearchControls cons = getSearchControls(returningAttributes, searchScope);
return execute(new LdapOperation<List<SearchResult>>() {
@Override
public List<SearchResult> execute(LdapContext context) throws NamingException {
NamingEnumeration<SearchResult> 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<String> 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<String> returningAttributes) {
final String filter = getFilterById(id);
try {
final SearchControls cons = getSearchControls(returningAttributes, this.config.getSearchScope());
return execute(new LdapOperation<SearchResult>() {
@Override
public SearchResult execute(LdapContext context) throws NamingException {
NamingEnumeration<SearchResult> 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);
}
}
/**
* <p>
* Destroys a subcontext with the given DN from the LDAP tree.
* </p>
*/
private void destroySubcontext(LdapContext context, final String dn) {
try {
NamingEnumeration<Binding> 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);
}
}
/**
* <p>
* Performs a simple authentication using the given DN and password to bind to the authentication context.
* </p>
*
* @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<Object, Object> 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<Void>() {
@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<? extends Attribute> 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<Void>() {
@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<String> 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> R execute(LdapOperation<R> operation) throws NamingException {
return execute(operation, null);
}
private <R> R execute(LdapOperation<R> operation, LdapMapOperationDecorator decorator) throws NamingException {
try (LdapMapContextManager ldapMapContextManager = LdapMapContextManager.create(session, config)) {
return execute(operation, ldapMapContextManager.getLdapContext(), decorator);
}
}
private <R> R execute(LdapOperation<R> 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> {
R execute(LdapContext context) throws NamingException;
}
private Set<String> getReturningAttributes(final Collection<String> returningAttributes) {
Set<String> result = new HashSet<>(returningAttributes);
result.add(getUuidAttributeName());
result.add(LDAPConstants.OBJECT_CLASS);
return result;
}
}

View file

@ -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;
/**
* <p>Utility class for working with LDAP.</p>
*
* @author Pedro Igor
*/
public class LdapMapUtil {
/**
* <p>Formats the given date.</p>
*
* @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);
}
/**
* <p>
* Parses dates/time stamps stored in LDAP. Some possible values:
* </p>
* <ul>
* <li>20020228150820</li>
* <li>20030228150820Z</li>
* <li>20050228150820.12</li>
* <li>20060711011740.0Z</li>
* </ul>
*
* @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);
}
}
/**
* <p>Creates a byte-based {@link String} representation of a raw byte array representing the value of the
* <code>objectGUID</code> attribute retrieved from Active Directory.</p>
*
* <p>The returned string is useful to perform queries on AD based on the <code>objectGUID</code> value. Eg.:</p>
*
* <p>
* String filter = "(&(objectClass=*)(objectGUID" + EQUAL + convertObjectGUIDToByteString(objectGUID) + "))";
* </p>
*
* @param objectGUID A raw byte array representing the value of the <code>objectGUID</code> 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();
}
/**
* <p>Encode a string representing the display value of the <code>objectGUID</code> attribute retrieved from Active
* Directory.</p>
*
* @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 <code>objectGUID</code> 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;
}
/**
* <p>Decode a raw byte array representing the value of the <code>objectGUID</code> attribute retrieved from Active
* Directory.</p>
*
* <p>The returned string is useful to directly bind an entry. Eg.:</p>
*
* <p>
* String bindingString = decodeObjectGUID(objectGUID);
* <br/>
* Attributes attributes = ctx.getAttributes(bindingString);
* </p>
*
* @param objectGUID A raw byte array representing the value of the <code>objectGUID</code> 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);
}
/**
* <p>Decode a raw byte array representing the value of the <code>guid</code> attribute retrieved from Novell
* eDirectory.</p>
*
* @param guid A raw byte array representing the value of the <code>guid</code> 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);
}
}
}

View file

@ -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

View file

@ -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<String, String> props;
private Config(String prefix, Map<String, String> 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);
}
}

View file

@ -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"));
}
}

View file

@ -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("()*\\"));
}
}

View file

@ -169,7 +169,7 @@ public class DeepCloner {
* *
* @param <V> Class or interface that would be instantiated by the given methods * @param <V> 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 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. * If {@code null}, such a single-parameter constructor is not available.
* @return This builder. * @return This builder.
*/ */
@ -185,7 +185,7 @@ public class DeepCloner {
* *
* @param <V> Class or interface that would be instantiated by the given methods * @param <V> 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 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. * If {@code null}, such a single-parameter constructor is not available.
* @return This builder. * @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. * Returns a class type of an instance that would be instantiated by {@link #newInstance(java.lang.Class)} method.
* @param <V> Type (class or a {@code @Root} interface) to create a new instance * @param <V> 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 * @return See description
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

View file

@ -71,7 +71,7 @@ public class MapRoleProvider implements RoleProvider {
entity.setRealmId(realm.getId()); entity.setRealmId(realm.getId());
entity.setName(name); entity.setName(name);
entity.setClientRole(false); entity.setClientRole(false);
if (tx.read(entity.getId()) != null) { if (entity.getId() != null && tx.read(entity.getId()) != null) {
throw new ModelDuplicateException("Role exists: " + id); throw new ModelDuplicateException("Role exists: " + id);
} }
entity = tx.create(entity); entity = tx.create(entity);
@ -129,7 +129,7 @@ public class MapRoleProvider implements RoleProvider {
entity.setName(name); entity.setName(name);
entity.setClientRole(true); entity.setClientRole(true);
entity.setClientId(client.getId()); 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); throw new ModelDuplicateException("Role exists: " + id);
} }
entity = tx.create(entity); entity = tx.create(entity);
@ -244,7 +244,8 @@ public class MapRoleProvider implements RoleProvider {
MapRoleEntity entity = tx.read(id); MapRoleEntity entity = tx.read(id);
String realmId = realm.getId(); 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 ? null
: entityToAdapterFunc(realm).apply(entity); : entityToAdapterFunc(realm).apply(entity);
} }

View file

@ -29,8 +29,12 @@ public interface MapKeycloakTransaction<V extends AbstractEntity, M> extends Key
* Updates to the returned instances of {@code V} would be visible in the current transaction * Updates to the returned instances of {@code V} would be visible in the current transaction
* and will propagate into the underlying store upon commit. * 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 * @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); V create(V value);
@ -47,7 +51,7 @@ public interface MapKeycloakTransaction<V extends AbstractEntity, M> extends Key
/** /**
* Returns a stream of values from underlying storage that are updated based on the current transaction changes; * 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 * 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. * {@link MapKeycloakTransaction#delete}, etc.
* <p> * <p>
* Updates to the returned instances of {@code V} would be visible in the current transaction * Updates to the returned instances of {@code V} would be visible in the current transaction
@ -79,8 +83,6 @@ public interface MapKeycloakTransaction<V extends AbstractEntity, M> extends Key
/** /**
* Instructs this transaction to remove values (identified by {@code mcb} filter) from the underlying store on commit. * 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. * @param queryParameters parameters for the query like firstResult, maxResult, requested ordering, etc.
* @return number of removed objects (might return {@code -1} if not supported) * @return number of removed objects (might return {@code -1} if not supported)
*/ */

View file

@ -35,12 +35,12 @@ import java.util.stream.Collectors;
public class MapModelCriteriaBuilder<K, V extends AbstractEntity, M> implements ModelCriteriaBuilder<M, MapModelCriteriaBuilder<K, V, M>> { public class MapModelCriteriaBuilder<K, V extends AbstractEntity, M> implements ModelCriteriaBuilder<M, MapModelCriteriaBuilder<K, V, M>> {
@FunctionalInterface @FunctionalInterface
public static interface UpdatePredicatesFunc<K, V extends AbstractEntity, M> { public interface UpdatePredicatesFunc<K, V extends AbstractEntity, M> {
MapModelCriteriaBuilder<K, V, M> apply(MapModelCriteriaBuilder<K, V, M> builder, Operator op, Object[] params); MapModelCriteriaBuilder<K, V, M> apply(MapModelCriteriaBuilder<K, V, M> builder, Operator op, Object[] params);
} }
private static final Predicate<Object> ALWAYS_TRUE = (e) -> true; protected static final Predicate<Object> ALWAYS_TRUE = (e) -> true;
private static final Predicate<Object> ALWAYS_FALSE = (e) -> false; protected static final Predicate<Object> ALWAYS_FALSE = (e) -> false;
private final Predicate<? super K> keyFilter; private final Predicate<? super K> keyFilter;
private final Predicate<? super V> entityFilter; private final Predicate<? super V> entityFilter;
private final Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates; private final Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates;
@ -50,7 +50,7 @@ public class MapModelCriteriaBuilder<K, V extends AbstractEntity, M> implements
this(keyConvertor, fieldPredicates, ALWAYS_TRUE, ALWAYS_TRUE); this(keyConvertor, fieldPredicates, ALWAYS_TRUE, ALWAYS_TRUE);
} }
private MapModelCriteriaBuilder(StringKeyConvertor<K> keyConvertor, Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates, Predicate<? super K> indexReadFilter, Predicate<? super V> sequentialReadFilter) { protected MapModelCriteriaBuilder(StringKeyConvertor<K> keyConvertor, Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates, Predicate<? super K> indexReadFilter, Predicate<? super V> sequentialReadFilter) {
this.keyConvertor = keyConvertor; this.keyConvertor = keyConvertor;
this.fieldPredicates = fieldPredicates; this.fieldPredicates = fieldPredicates;
this.keyFilter = indexReadFilter; this.keyFilter = indexReadFilter;
@ -73,7 +73,7 @@ public class MapModelCriteriaBuilder<K, V extends AbstractEntity, M> implements
public final MapModelCriteriaBuilder<K, V, M> and(MapModelCriteriaBuilder<K, V, M>... builders) { public final MapModelCriteriaBuilder<K, V, M> and(MapModelCriteriaBuilder<K, V, M>... builders) {
Predicate<? super K> resIndexFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getKeyFilter).reduce(keyFilter, Predicate::and); Predicate<? super K> resIndexFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getKeyFilter).reduce(keyFilter, Predicate::and);
Predicate<V> resEntityFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getEntityFilter).reduce(entityFilter, Predicate::and); Predicate<V> 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 @SafeVarargs
@ -82,7 +82,7 @@ public class MapModelCriteriaBuilder<K, V extends AbstractEntity, M> implements
public final MapModelCriteriaBuilder<K, V, M> or(MapModelCriteriaBuilder<K, V, M>... builders) { public final MapModelCriteriaBuilder<K, V, M> or(MapModelCriteriaBuilder<K, V, M>... builders) {
Predicate<? super K> resIndexFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getKeyFilter).reduce(ALWAYS_FALSE, Predicate::or); Predicate<? super K> resIndexFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getKeyFilter).reduce(ALWAYS_FALSE, Predicate::or);
Predicate<V> resEntityFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getEntityFilter).reduce(ALWAYS_FALSE, Predicate::or); Predicate<V> resEntityFilter = Stream.of(builders).map(MapModelCriteriaBuilder.class::cast).map(MapModelCriteriaBuilder::getEntityFilter).reduce(ALWAYS_FALSE, Predicate::or);
return new MapModelCriteriaBuilder<>( return instantiateNewInstance(
keyConvertor, keyConvertor,
fieldPredicates, fieldPredicates,
v -> keyFilter.test(v) && resIndexFilter.test(v), v -> keyFilter.test(v) && resIndexFilter.test(v),
@ -90,14 +90,12 @@ public class MapModelCriteriaBuilder<K, V extends AbstractEntity, M> implements
); );
} }
@SuppressWarnings("unchecked")
@Override @Override
public MapModelCriteriaBuilder<K, V, M> not(MapModelCriteriaBuilder<K, V, M> builder) { public MapModelCriteriaBuilder<K, V, M> not(MapModelCriteriaBuilder<K, V, M> builder) {
MapModelCriteriaBuilder<K, V, M> b = (MapModelCriteriaBuilder<K, V, M>) builder;
Predicate<? super K> resIndexFilter = builder.getKeyFilter() == ALWAYS_TRUE ? ALWAYS_TRUE : builder.getKeyFilter().negate(); Predicate<? super K> resIndexFilter = builder.getKeyFilter() == ALWAYS_TRUE ? ALWAYS_TRUE : builder.getKeyFilter().negate();
Predicate<? super V> resEntityFilter = builder.getEntityFilter() == ALWAYS_TRUE ? ALWAYS_TRUE : builder.getEntityFilter().negate(); Predicate<? super V> resEntityFilter = builder.getEntityFilter() == ALWAYS_TRUE ? ALWAYS_TRUE : builder.getEntityFilter().negate();
return new MapModelCriteriaBuilder<>( return instantiateNewInstance(
keyConvertor, keyConvertor,
fieldPredicates, fieldPredicates,
v -> keyFilter.test(v) && resIndexFilter.test(v), v -> keyFilter.test(v) && resIndexFilter.test(v),
@ -125,7 +123,7 @@ public class MapModelCriteriaBuilder<K, V extends AbstractEntity, M> implements
case EXISTS: case EXISTS:
case NOT_EXISTS: case NOT_EXISTS:
case IN: 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: default:
throw new AssertionError("Invalid operator: " + op); throw new AssertionError("Invalid operator: " + op);
} }
@ -167,6 +165,16 @@ public class MapModelCriteriaBuilder<K, V extends AbstractEntity, M> implements
final Predicate<V> p = v -> valueComparator.test(getter.apply(v)); final Predicate<V> p = v -> valueComparator.test(getter.apply(v));
resEntityFilter = p.and(entityFilter); 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<K, V, M> instantiateNewInstance(StringKeyConvertor<K> keyConvertor, Map<SearchableModelField<? super M>, UpdatePredicatesFunc<K, V, M>> fieldPredicates, Predicate<? super K> indexReadFilter, Predicate<? super V> sequentialReadFilter) {
return new MapModelCriteriaBuilder<>(keyConvertor, fieldPredicates, indexReadFilter, sequentialReadFilter);
} }
} }

View file

@ -37,5 +37,6 @@
<module>map</module> <module>map</module>
<module>build-processor</module> <module>build-processor</module>
<module>map-hot-rod</module> <module>map-hot-rod</module>
<module>map-ldap</module>
</modules> </modules>
</project> </project>

View file

@ -1274,6 +1274,11 @@
<artifactId>keycloak-model-map-jpa</artifactId> <artifactId>keycloak-model-map-jpa</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-map-ldap</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-model-infinispan</artifactId> <artifactId>keycloak-model-infinispan</artifactId>

View file

@ -96,6 +96,10 @@
<groupId>org.keycloak</groupId> <groupId>org.keycloak</groupId>
<artifactId>keycloak-model-map-hot-rod</artifactId> <artifactId>keycloak-model-map-hot-rod</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-map-ldap</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.infinispan</groupId> <groupId>org.infinispan</groupId>
<artifactId>infinispan-server-core</artifactId> <artifactId>infinispan-server-core</artifactId>
@ -303,6 +307,15 @@
<keycloak.model.parameters>Jpa,Map,HotRodMapStorage</keycloak.model.parameters> <keycloak.model.parameters>Jpa,Map,HotRodMapStorage</keycloak.model.parameters>
</properties> </properties>
</profile> </profile>
<profile>
<id>map-ldap</id>
<properties>
<keycloak.profile.feature.map_storage>enabled</keycloak.profile.feature.map_storage>
<keycloak.model.parameters>Jpa,Map,LdapMapStorage</keycloak.model.parameters>
</properties>
</profile>
</profiles> </profiles>
</project> </project>

View file

@ -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<Class<? extends Spi>> ALLOWED_SPIS = ImmutableSet.<Class<? extends Spi>>builder()
.build();
static final Set<Class<? extends ProviderFactory>> ALLOWED_FACTORIES = ImmutableSet.<Class<? extends ProviderFactory>>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);
}
}

View file

@ -286,7 +286,6 @@
<configuration> <configuration>
<systemProperties> <systemProperties>
<systemProperty><key>keycloak.profile.feature.map_storage</key><value>enabled</value></systemProperty> <systemProperty><key>keycloak.profile.feature.map_storage</key><value>enabled</value></systemProperty>
<systemProperty><key>keycloak.mapStorage.provider</key><value>concurrenthashmap</value></systemProperty>
<systemProperty><key>keycloak.realm.provider</key><value>map</value></systemProperty> <systemProperty><key>keycloak.realm.provider</key><value>map</value></systemProperty>
<systemProperty><key>keycloak.client.provider</key><value>map</value></systemProperty> <systemProperty><key>keycloak.client.provider</key><value>map</value></systemProperty>
<systemProperty><key>keycloak.clientScope.provider</key><value>map</value></systemProperty> <systemProperty><key>keycloak.clientScope.provider</key><value>map</value></systemProperty>

View file

@ -117,6 +117,26 @@
"driver": "org.postgresql.Driver", "driver": "org.postgresql.Driver",
"driverDialect": "org.keycloak.models.map.storage.jpa.hibernate.dialect.JsonbPostgreSQL95Dialect", "driverDialect": "org.keycloak.models.map.storage.jpa.hibernate.dialect.JsonbPostgreSQL95Dialect",
"showSql": "${keycloak.map.storage.connectionsJpa,showSql:false}" "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": { "connectionsInfinispan": {
"default": { "default": {
"jgroupsUdpMcastAddr": "${keycloak.connectionsInfinispan.jgroupsUdpMcastAddr:234.56.78.90}", "jgroupsUdpMcastAddr": "${keycloak.connectionsInfinispan.jgroupsUdpMcastAddr:234.56.78.90}",