From 85e009c701decb9b43539b1b6c48d3b10f76383e Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Tue, 8 Mar 2022 19:01:39 +0100 Subject: [PATCH] Implement LDAP Map storage for users (excluding support for roles) This includes SPNEGO for ApacheDS and ActiveDirectory Closes #9930 --- .../kerberos/KerberosFederationProvider.java | 8 +- .../kerberos/impl/SPNEGOAuthenticator.java | 7 + .../storage/ldap/LdapMapStorageProvider.java | 15 +- .../ldap/LdapMapStorageProviderFactory.java | 28 +- .../ldap/config/LdapKerberosConfig.java | 30 ++ .../storage/ldap/config/LdapMapConfig.java | 4 + .../role/LdapRoleMapKeycloakTransaction.java | 24 +- .../ldap/role/entity/LdapRoleEntity.java | 29 ++ .../ldap/store/LdapMapIdentityStore.java | 51 +++ .../ldap/store/LdapMapOperationManager.java | 19 + .../store/LdapMapPasswordModifyRequest.java | 118 +++++ .../user/LdapUserMapKeycloakTransaction.java | 429 ++++++++++++++++++ .../user/LdapUserModelCriteriaBuilder.java | 172 +++++++ .../user/config/LdapMapUserMapperConfig.java | 120 +++++ ...LdapSingleUserCredentialManagerEntity.java | 78 ++++ .../LdapMapUserEntityFieldDelegate.java | 52 +++ .../ldap/user/entity/LdapUserEntity.java | 313 +++++++++++++ .../user/kerberos/CommonKerberosConfig.java | 64 +++ .../KerberosServerSubjectAuthenticator.java | 81 ++++ ...KerberosUsernamePasswordAuthenticator.java | 186 ++++++++ .../kerberos/impl/SPNEGOAuthenticator.java | 199 ++++++++ .../user/MapCredentialValidationOutput.java | 4 +- .../testsuite/pages/AbstractAccountPage.java | 9 + .../test/resources/kerberos/test-krb5.conf | 1 + .../model/parameters/LdapMapStorage.java | 65 ++- .../resources/META-INF/keycloak-server.json | 11 +- .../resources/kerberos/default-users.ldif | 10 + 27 files changed, 2103 insertions(+), 24 deletions(-) create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapKerberosConfig.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapPasswordModifyRequest.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/LdapUserMapKeycloakTransaction.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/LdapUserModelCriteriaBuilder.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/config/LdapMapUserMapperConfig.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/credential/LdapSingleUserCredentialManagerEntity.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/entity/LdapMapUserEntityFieldDelegate.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/entity/LdapUserEntity.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/CommonKerberosConfig.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/KerberosServerSubjectAuthenticator.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/KerberosUsernamePasswordAuthenticator.java create mode 100644 model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/SPNEGOAuthenticator.java diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProvider.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProvider.java index f0a1a785f71e..6c3d0d7736c1 100755 --- a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProvider.java +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProvider.java @@ -198,6 +198,11 @@ public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput Map state = new HashMap(); if (spnegoAuthenticator.isAuthenticated()) { String username = spnegoAuthenticator.getAuthenticatedUsername(); + if (!spnegoAuthenticator.getKerberosRealm().equals(kerberosConfig.getKerberosRealm())) { + // TODO: we would probably allow for users of other realms to authenticate as well if there is a trust relationship + // TODO: on the other hand how would we handle duplicate user names in the two realms? Would we combine the two to a long, distinct user name? + return CredentialValidationOutput.failed(); + } UserModel user = findOrCreateAuthenticatedUser(realm, username); if (user == null) { return CredentialValidationOutput.failed(); @@ -242,7 +247,8 @@ protected UserModel findOrCreateAuthenticatedUser(RealmModel realm, String usern user = session.users().getUserById(realm, user.getId()); // make sure we get a cached instance logger.debug("Kerberos authenticated user " + username + " found in Keycloak storage"); - if (!model.getId().equals(user.getFederationLink())) { + // TODO: revisit how federationLink would be set on map storage and if `user.getFederationLink() != null` is the right way to go + if (user.getFederationLink() != null && !model.getId().equals(user.getFederationLink())) { logger.warn("User with username " + username + " already exists, but is not linked to provider [" + model.getName() + "]"); return null; } else { diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/SPNEGOAuthenticator.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/SPNEGOAuthenticator.java index 8fe53209f50c..6e8e1d9a6534 100644 --- a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/SPNEGOAuthenticator.java +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/SPNEGOAuthenticator.java @@ -118,6 +118,13 @@ public String getAuthenticatedUsername() { return username; } + /** + * @return username to be used in Keycloak. Username is authenticated kerberos principal without realm name + */ + public String getKerberosRealm() { + String[] tokens = authenticatedKerberosPrincipal.split("@"); + return tokens[1]; + } private class AcceptSecContext implements PrivilegedExceptionAction { diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProvider.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProvider.java index 1c5002b6b808..440cb344eccd 100644 --- a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProvider.java +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProvider.java @@ -17,20 +17,26 @@ package org.keycloak.models.map.storage.ldap; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; 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; +import org.keycloak.models.map.storage.ldap.user.LdapUserMapKeycloakTransaction; +import org.keycloak.models.map.user.MapUserEntity; public class LdapMapStorageProvider implements MapStorageProvider { private final LdapMapStorageProviderFactory factory; private final String sessionTxPrefix; + @Deprecated + private final MapStorageProvider delegate; - public LdapMapStorageProvider(LdapMapStorageProviderFactory factory, String sessionTxPrefix) { + public LdapMapStorageProvider(LdapMapStorageProviderFactory factory, String sessionTxPrefix, MapStorageProvider delegate) { this.factory = factory; this.sessionTxPrefix = sessionTxPrefix; + this.delegate = delegate; } @Override @@ -49,6 +55,13 @@ public MapKeycloakTransaction createTransaction(KeycloakSession session) { if (sessionTx == null) { sessionTx = factory.createTransaction(session, modelType); session.setAttribute(sessionTxPrefix + modelType.hashCode(), sessionTx); + + if (modelType == UserModel.class) { + MapStorage delegateStorage = delegate.getStorage(modelType, flags); + MapKeycloakTransaction delegateTransaction = delegateStorage.createTransaction(session); + ((LdapUserMapKeycloakTransaction) sessionTx).setDelegate((MapKeycloakTransaction) delegateTransaction); + } + } return sessionTx; } diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProviderFactory.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProviderFactory.java index 985f209050e2..75f82b48c62f 100644 --- a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProviderFactory.java +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/LdapMapStorageProviderFactory.java @@ -18,11 +18,13 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; import org.keycloak.Config; import org.keycloak.common.Profile; import org.keycloak.component.AmphibianProviderFactory; +import org.keycloak.models.UserModel; import org.keycloak.models.map.common.AbstractEntity; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -32,6 +34,7 @@ import org.keycloak.models.map.storage.MapStorageProviderFactory; import org.keycloak.models.map.storage.ldap.config.LdapMapConfig; import org.keycloak.models.map.storage.ldap.role.LdapRoleMapKeycloakTransaction; +import org.keycloak.models.map.storage.ldap.user.LdapUserMapKeycloakTransaction; import org.keycloak.provider.EnvironmentDependentProviderFactory; public class LdapMapStorageProviderFactory implements @@ -46,10 +49,18 @@ public class LdapMapStorageProviderFactory implements private Config.Scope config; + /* + * TODO: This delegate will disappear in the final implementation. It's a helper for development when an entity is not fully + * supported and the tree storage can't be configured for it yet. + */ + @Deprecated + private volatile MapStorageProvider delegate; + @SuppressWarnings("rawtypes") private static final Map, LdapRoleMapKeycloakTransaction.LdapRoleMapKeycloakTransactionFunction> MODEL_TO_TX = new HashMap<>(); static { MODEL_TO_TX.put(RoleModel.class, LdapRoleMapKeycloakTransaction::new); + MODEL_TO_TX.put(UserModel.class, LdapUserMapKeycloakTransaction::new); } public LdapMapStorageProviderFactory() { @@ -57,12 +68,15 @@ public LdapMapStorageProviderFactory() { } public MapKeycloakTransaction createTransaction(KeycloakSession session, Class modelType) { - return MODEL_TO_TX.get(modelType).apply(session, config); + LdapRoleMapKeycloakTransaction.LdapRoleMapKeycloakTransactionFunction tx = MODEL_TO_TX.get(modelType); + Objects.requireNonNull(tx, "model " + modelType + " is not supported for " + this.getClass()); + return tx.apply(session, config); } @Override public MapStorageProvider create(KeycloakSession session) { - return new LdapMapStorageProvider(this, sessionTxPrefixForFactoryInstance); + lazyInit(session); + return new LdapMapStorageProvider(this, sessionTxPrefixForFactoryInstance, delegate); } @Override @@ -89,6 +103,16 @@ private static void checkSystemProperty(String name, String cfgValue, String def System.setProperty(name, value); } + private void lazyInit(KeycloakSession session) { + if (delegate == null) { + synchronized (this) { + if (delegate == null) { + delegate = session.getProvider(MapStorageProvider.class, "concurrenthashmap"); + } + } + } + } + @Override public void postInit(KeycloakSessionFactory factory) { } diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapKerberosConfig.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapKerberosConfig.java new file mode 100644 index 000000000000..adb97038856f --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapKerberosConfig.java @@ -0,0 +1,30 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.config; + +import org.keycloak.models.map.storage.ldap.user.kerberos.CommonKerberosConfig; + +/** + * @author Alexander Schwartz + */ +public class LdapKerberosConfig extends CommonKerberosConfig { + + public LdapKerberosConfig(LdapMapConfig config) { + super(config.getConfig()); + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapConfig.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapConfig.java index 4490981172a5..3190f52e5760 100644 --- a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapConfig.java +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/config/LdapMapConfig.java @@ -44,6 +44,10 @@ public String getFirst(String key) { }; } + protected MultivaluedHashMap getConfig() { + return config; + } + // from: RoleMapperConfig public Collection getRoleObjectClasses() { String objectClasses = config.getFirst(LdapMapRoleMapperConfig.ROLE_OBJECT_CLASSES); diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleMapKeycloakTransaction.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleMapKeycloakTransaction.java index 68a0752b3574..af9f33e2f9a0 100644 --- a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleMapKeycloakTransaction.java +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/LdapRoleMapKeycloakTransaction.java @@ -189,22 +189,22 @@ public void execute() { @Override public boolean delete(String key) { + if (deletedKeys.contains(key)) { + return true; + } LdapMapRoleEntityFieldDelegate read = read(key); if (read == null) { throw new ModelException("unable to read entity with key " + key); } - if (!deletedKeys.contains((key))) { - // avoid enlisting LDAP removal twice if client calls it twice - deletedKeys.add(key); - tasksOnCommit.add(new DeleteOperation() { - @Override - public void execute() { - identityStore.remove(read.getLdapMapObject()); - // once removed from LDAP, avoid updating a modified entity in LDAP. - entities.remove(read.getId()); - } - }); - } + deletedKeys.add(key); + tasksOnCommit.add(new DeleteOperation() { + @Override + public void execute() { + identityStore.remove(read.getLdapMapObject()); + // once removed from LDAP, avoid updating a modified entity in LDAP. + entities.remove(read.getId()); + } + }); return true; } diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapRoleEntity.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapRoleEntity.java index 0acda096402b..661e1ccb492d 100644 --- a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapRoleEntity.java +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/role/entity/LdapRoleEntity.java @@ -332,4 +332,33 @@ public > & EntityField> & EntityField> Object mapGet(EF field, K key) { + if (field == MapRoleEntityFields.ATTRIBUTES) { + return getAttribute((String) key); + } else { + throw new ModelException("unsupported field for mapGet " + field); + } + } + + @Override + public > & EntityField> void mapPut(EF field, K key, T value) { + if (field == MapRoleEntityFields.ATTRIBUTES) { + //noinspection unchecked + setAttribute((String) key, (List) value); + } else { + throw new ModelException("unsupported field for mapGetPut " + field); + } + } + + @Override + public > & EntityField> Object mapRemove(EF field, K key) { + if (field == MapRoleEntityFields.ATTRIBUTES) { + removeAttribute((String) key); + return null; + } else { + throw new ModelException("unsupported field for mapRemove " + field); + } + } + } diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapIdentityStore.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapIdentityStore.java index 2ad1cb137f77..46b3ca3a562d 100644 --- a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapIdentityStore.java +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapIdentityStore.java @@ -44,6 +44,7 @@ import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -531,6 +532,56 @@ protected String getEntryIdentifier(final LdapMapObject ldapObject) { } } + public void updatePassword(LdapMapObject user, String password, LdapMapOperationDecorator passwordUpdateDecorator) { + String userDN = user.getDn().toString(); + + if (logger.isDebugEnabled()) { + logger.debugf("Using DN [%s] for updating LDAP password of user", userDN); + } + + if (getConfig().isActiveDirectory()) { + updateADPassword(userDN, password, passwordUpdateDecorator); + return; + } + + try { + if (config.useExtendedPasswordModifyOp()) { + operationManager.passwordModifyExtended(userDN, password, passwordUpdateDecorator); + } else { + ModificationItem[] mods = new ModificationItem[1]; + BasicAttribute mod0 = new BasicAttribute(LDAPConstants.USER_PASSWORD_ATTRIBUTE, password); + mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, mod0); + operationManager.modifyAttributes(userDN, mods, passwordUpdateDecorator); + } + } catch (ModelException me) { + throw me; + } catch (Exception e) { + throw new ModelException("Error updating password.", e); + } + } + + private void updateADPassword(String userDN, String password, LdapMapOperationDecorator passwordUpdateDecorator) { + try { + // Replace the "unicdodePwd" attribute with a new value + // Password must be both Unicode and a quoted string + String newQuotedPassword = "\"" + password + "\""; + byte[] newUnicodePassword = newQuotedPassword.getBytes(StandardCharsets.UTF_16LE); + + BasicAttribute unicodePwd = new BasicAttribute("unicodePwd", newUnicodePassword); + + List modItems = new ArrayList<>(); + modItems.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE, unicodePwd)); + + operationManager.modifyAttributes(userDN, modItems.toArray(new ModificationItem[] {}), passwordUpdateDecorator); + } catch (ModelException me) { + throw me; + } catch (Exception e) { + throw new ModelException(e); + } + } + + + @Override public void close() { operationManager.close(); diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationManager.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationManager.java index e907d0c2b1f6..a5cc7137795c 100644 --- a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationManager.java +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapOperationManager.java @@ -636,4 +636,23 @@ private Set getReturningAttributes(final Collection returningAtt return result; } + + /** + * Execute the LDAP Password Modify Extended Operation to update the password for the given DN. + * + * @param dn distinguished name of the entry. + * @param password the new password. + * @param decorator A decorator to apply to the ldap operation. + */ + + public void passwordModifyExtended(String dn, String password, LdapMapOperationDecorator decorator) { + try { + execute(context -> { + LdapMapPasswordModifyRequest modifyRequest = new LdapMapPasswordModifyRequest(dn, null, password); + return context.extendedOperation(modifyRequest); + }, decorator); + } catch (NamingException e) { + throw new ModelException("Could not execute the password modify extended operation for DN [" + dn + "]", e); + } + } } diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapPasswordModifyRequest.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapPasswordModifyRequest.java new file mode 100644 index 000000000000..59803f5c83e9 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/store/LdapMapPasswordModifyRequest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.ldap.store; + +import javax.naming.ldap.ExtendedRequest; +import javax.naming.ldap.ExtendedResponse; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * An implementation of the + * + * LDAP Password Modify Extended Operation + * + * client request. + *

+ * Can be directed at any LDAP server that supports the Password Modify Extended Operation. + * + * @author Josh Cummings + * @since 4.2.9 + */ +public final class LdapMapPasswordModifyRequest implements ExtendedRequest { + + public static final String PASSWORD_MODIFY_OID = "1.3.6.1.4.1.4203.1.11.1"; + + private static final byte SEQUENCE_TYPE = 48; + private static final byte USER_IDENTITY_OCTET_TYPE = -128; + private static final byte OLD_PASSWORD_OCTET_TYPE = -127; + private static final byte NEW_PASSWORD_OCTET_TYPE = -126; + + private final ByteArrayOutputStream value = new ByteArrayOutputStream(); + + public LdapMapPasswordModifyRequest(String userIdentity, String oldPassword, String newPassword) { + ByteArrayOutputStream elements = new ByteArrayOutputStream(); + + if (userIdentity != null) { + berEncode(USER_IDENTITY_OCTET_TYPE, userIdentity.getBytes(), elements); + } + + if (oldPassword != null) { + berEncode(OLD_PASSWORD_OCTET_TYPE, oldPassword.getBytes(), elements); + } + + if (newPassword != null) { + berEncode(NEW_PASSWORD_OCTET_TYPE, newPassword.getBytes(), elements); + } + + berEncode(SEQUENCE_TYPE, elements.toByteArray(), this.value); + } + + @Override + public String getID() { + return PASSWORD_MODIFY_OID; + } + + @Override + public byte[] getEncodedValue() { + return this.value.toByteArray(); + } + + @Override + public ExtendedResponse createExtendedResponse(String id, byte[] berValue, int offset, int length) { + return null; + } + + /** + * Only minimal support for + * + * BER encoding + * ; just what is necessary for the Password Modify request. + */ + private void berEncode(byte type, byte[] src, ByteArrayOutputStream dest) { + int length = src.length; + + dest.write(type); + + if (length < 128) { + dest.write(length); + } else if ((length & 0x0000_00FF) == length) { + dest.write((byte) 0x81); + dest.write((byte) (length & 0xFF)); + } else if ((length & 0x0000_FFFF) == length) { + dest.write((byte) 0x82); + dest.write((byte) ((length >> 8) & 0xFF)); + dest.write((byte) (length & 0xFF)); + } else if ((length & 0x00FF_FFFF) == length) { + dest.write((byte) 0x83); + dest.write((byte) ((length >> 16) & 0xFF)); + dest.write((byte) ((length >> 8) & 0xFF)); + dest.write((byte) (length & 0xFF)); + } else { + dest.write((byte) 0x84); + dest.write((byte) ((length >> 24) & 0xFF)); + dest.write((byte) ((length >> 16) & 0xFF)); + dest.write((byte) ((length >> 8) & 0xFF)); + dest.write((byte) (length & 0xFF)); + } + + try { + dest.write(src); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to BER encode provided value of type: " + type); + } + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/LdapUserMapKeycloakTransaction.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/LdapUserMapKeycloakTransaction.java new file mode 100644 index 000000000000..7954c4e70f70 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/LdapUserMapKeycloakTransaction.java @@ -0,0 +1,429 @@ +/* + * 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.user; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.credential.CredentialInput; +import org.keycloak.models.CredentialValidationOutput; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.common.StringKeyConverter; +import org.keycloak.models.map.storage.MapKeycloakTransaction; +import org.keycloak.models.map.storage.MapKeycloakTransactionWithAuth; +import org.keycloak.models.map.storage.criteria.DefaultModelCriteria; +import org.keycloak.models.map.storage.ldap.config.LdapKerberosConfig; +import org.keycloak.models.map.storage.ldap.user.kerberos.impl.KerberosServerSubjectAuthenticator; +import org.keycloak.models.map.storage.ldap.user.kerberos.impl.SPNEGOAuthenticator; +import org.keycloak.models.map.user.MapCredentialValidationOutput; +import org.keycloak.models.map.user.MapUserEntity; +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.LdapMapKeycloakTransaction; +import org.keycloak.models.map.storage.ldap.MapModelCriteriaBuilderAssumingEqualForField; +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.models.map.storage.ldap.store.LdapMapIdentityStore; +import org.keycloak.models.map.storage.ldap.user.config.LdapMapUserMapperConfig; +import org.keycloak.models.map.storage.ldap.user.entity.LdapMapUserEntityFieldDelegate; +import org.keycloak.models.map.storage.ldap.user.entity.LdapUserEntity; +import org.keycloak.provider.Provider; + +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; + +import static org.keycloak.models.map.storage.QueryParameters.withCriteria; +import static org.keycloak.models.map.storage.criteria.DefaultModelCriteria.criteria; + +public class LdapUserMapKeycloakTransaction extends LdapMapKeycloakTransaction + implements Provider, MapKeycloakTransactionWithAuth { + + private static final Logger logger = Logger.getLogger(LdapUserMapKeycloakTransaction.class); + private final StringKeyConverter keyConverter = new StringKeyConverter.StringKey(); + private final Set deletedKeys = new HashSet<>(); + private final LdapMapUserMapperConfig userMapperConfig; + private final LdapMapConfig ldapMapConfig; + private final LdapMapIdentityStore identityStore; + + @Deprecated + private MapKeycloakTransaction delegate; + + public LdapUserMapKeycloakTransaction(KeycloakSession session, Config.Scope config) { + this.userMapperConfig = new LdapMapUserMapperConfig(config); + this.ldapMapConfig = new LdapMapConfig(config); + this.identityStore = new LdapMapIdentityStore(session, ldapMapConfig); + session.enlistForClose(this); + } + + public void setDelegate(MapKeycloakTransaction delegate) { + this.delegate = delegate; + } + + public MapKeycloakTransaction getDelegate() { + return delegate; + } + + public LdapMapIdentityStore getIdentityStore() { + return identityStore; + } + + @Override + public MapCredentialValidationOutput authenticate(RealmModel realm, CredentialInput input) { + if (!(input instanceof UserCredentialModel)) return null; + UserCredentialModel credential = (UserCredentialModel)input; + if (credential.getType().equals(UserCredentialModel.KERBEROS)) { + String spnegoToken = credential.getChallengeResponse(); + + LdapKerberosConfig kerberosConfig = new LdapKerberosConfig(ldapMapConfig); + KerberosServerSubjectAuthenticator kerberosAuth = new KerberosServerSubjectAuthenticator(kerberosConfig); + SPNEGOAuthenticator spnegoAuthenticator = new SPNEGOAuthenticator(kerberosConfig, kerberosAuth, spnegoToken); + spnegoAuthenticator.authenticate(); + + Map state = new HashMap<>(); + if (spnegoAuthenticator.isAuthenticated()) { + MapUserEntity user = findOrCreateAuthenticatedUser(realm, spnegoAuthenticator.getAuthenticatedUsername(), spnegoAuthenticator.getKerberosRealm()); + if (user == null) { + return MapCredentialValidationOutput.failed(); + } else { + String delegationCredential = spnegoAuthenticator.getSerializedDelegationCredential(); + if (delegationCredential != null) { + state.put(KerberosConstants.GSS_DELEGATION_CREDENTIAL, delegationCredential); + } + return new MapCredentialValidationOutput<>(user, CredentialValidationOutput.Status.AUTHENTICATED, state); + } + } else if (spnegoAuthenticator.getResponseToken() != null) { + // Case when SPNEGO handshake requires multiple steps + logger.tracef("SPNEGO Handshake will continue"); + state.put(KerberosConstants.RESPONSE_TOKEN, spnegoAuthenticator.getResponseToken()); + return new MapCredentialValidationOutput<>(null, CredentialValidationOutput.Status.CONTINUE, state); + } else { + logger.tracef("SPNEGO Handshake not successful"); + return MapCredentialValidationOutput.failed(); + } + + } else { + return null; + } + } + + private MapUserEntity findOrCreateAuthenticatedUser(RealmModel realm, String username, String kerberosRealm) { + DefaultModelCriteria mcb = criteria(); + mcb = mcb.compare(UserModel.SearchableFields.REALM_ID, ModelCriteriaBuilder.Operator.EQ, realm.getId()) + .compare(UserModel.SearchableFields.USERNAME, ModelCriteriaBuilder.Operator.EQ, username) + // .compare(UserModel.SearchableFields.FEDERATION_LINK, ModelCriteriaBuilder.Operator.EQ, kerberosRealm) + ; + List users = read(withCriteria(mcb)).limit(2).collect(Collectors.toList()); + if (users.size() != 1) { + return null; + } else { + return users.get(0); + } + + } + + @Override + public void close() { + identityStore.close(); + } + + // interface matching the constructor of this class + public interface LdapUserMapKeycloakTransactionFunction { + R apply(A a, B b); + } + + // TODO: entries might get stale if a DN of an entry changes due to changes in the entity in the same transaction + private final Map dns = new HashMap<>(); + + public String readIdByDn(String dn) { + // TODO: this might not be necessary if the LDAP server would support an extended OID + // https://ldapwiki.com/wiki/LDAP_SERVER_EXTENDED_DN_OID + + String id = dns.get(dn); + if (id == null) { + for (Map.Entry entry : entities.entrySet()) { + LdapMapObject ldap = entry.getValue().getLdapMapObject(); + if (ldap.getDn().toString().equals(dn)) { + id = ldap.getId(); + break; + } + } + } + if (id != null) { + return id; + } + + LdapMapQuery ldapQuery = new LdapMapQuery(); + + // For now, use same search scope, which is configured "globally" and used for user's search. + ldapQuery.setSearchScope(ldapMapConfig.getSearchScope()); + ldapQuery.setSearchDn(userMapperConfig.getCommonUsersDn()); + + // TODO: read them properly to be able to store them in the transaction so they are cached?! + Collection userObjectClasses = ldapMapConfig.getUserObjectClasses(); + ldapQuery.addObjectClasses(userObjectClasses); + + String usersRdnAttr = userMapperConfig.getUserNameLdapAttribute(); + + ldapQuery.addReturningLdapAttribute(usersRdnAttr); + ldapQuery.addReturningLdapAttribute("sn"); + ldapQuery.addReturningLdapAttribute("cn"); + ldapQuery.addReturningLdapAttribute("mail"); + + LdapMapDn.RDN rdn = LdapMapDn.fromString(dn).getFirstRdn(); + String key = rdn.getAllKeys().get(0); + String value = rdn.getAttrValue(key); + + LdapUserModelCriteriaBuilder mcb = + new LdapUserModelCriteriaBuilder(userMapperConfig).compare(UserModel.SearchableFields.USERNAME, ModelCriteriaBuilder.Operator.EQ, value); + mcb = mcb.withCustomFilter(userMapperConfig.getCustomLdapFilter()); + ldapQuery.setModelCriteriaBuilder(mcb); + + List ldapObjects = identityStore.fetchQueryResults(ldapQuery); + if (ldapObjects.size() == 1) { + dns.put(dn, ldapObjects.get(0).getId()); + return ldapObjects.get(0).getId(); + } + return null; + } + + private MapModelCriteriaBuilder createCriteriaBuilderMap() { + // The realmId might not be set of instances retrieved by read(id) and we're still sure that they belong to the realm being searched. + // Therefore, ignore the field realmId when searching the instances that are stored within the transaction. + return new MapModelCriteriaBuilderAssumingEqualForField<>(keyConverter, MapFieldPredicates.getPredicates(UserModel.class), UserModel.SearchableFields.REALM_ID); + } + + @Override + public LdapMapUserEntityFieldDelegate create(MapUserEntity value) { + DeepCloner CLONER = new DeepCloner.Builder() + .constructor(MapUserEntity.class, cloner -> new LdapMapUserEntityFieldDelegate(new LdapUserEntity(cloner, userMapperConfig, this))) + .build(); + + LdapMapUserEntityFieldDelegate mapped = (LdapMapUserEntityFieldDelegate) CLONER.from(value); + + // LDAP should never use the UUID provided by the caller, as UUID is generated by the LDAP directory + mapped.setId(null); + + // in order to get the ID, we need to write it to LDAP + identityStore.add(mapped.getLdapMapObject()); + // TODO: add a flag for temporary created users until they are finally committed so that they don't show up in ready(query) in their temporary state + + mapped.getEntityFieldDelegate().createDelegate(); + + 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) { + if (deletedKeys.contains(key)) { + return true; + } + LdapMapUserEntityFieldDelegate 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()); + // once removed from LDAP, avoid updating a modified entity in LDAP. + entities.remove(key); + } + }); + delegate.delete(key); + return true; + } + + @Override + public LdapMapUserEntityFieldDelegate read(String key) { + if (deletedKeys.contains(key)) { + return null; + } + + // reuse an existing live entity + LdapMapUserEntityFieldDelegate val = entities.get(key); + + if (val == null) { + + // try to look it up as a realm user + val = lookupEntityById(key); + + if (val != null) { + entities.put(key, val); + } + + } + return val; + } + + private LdapMapUserEntityFieldDelegate lookupEntityById(String id) { + LdapMapQuery ldapQuery = getLdapQuery(); + + LdapMapObject ldapObject = identityStore.fetchById(id, ldapQuery); + if (ldapObject != null) { + return new LdapMapUserEntityFieldDelegate(new LdapUserEntity(ldapObject, userMapperConfig, this)); + } + return null; + } + + @Override + public Stream read(QueryParameters queryParameters) { + LdapUserModelCriteriaBuilder mcb = queryParameters.getModelCriteriaBuilder() + .flashToModelCriteriaBuilder(createLdapModelCriteriaBuilder()); + + LdapMapQuery ldapQuery = getLdapQuery(); + + mcb = mcb.withCustomFilter(userMapperConfig.getCustomLdapFilter()); + ldapQuery.setModelCriteriaBuilder(mcb); + + Stream ldapStream; + + MapModelCriteriaBuilder mapMcb = queryParameters.getModelCriteriaBuilder().flashToModelCriteriaBuilder(createCriteriaBuilderMap()); + + Stream existingEntities = entities.entrySet().stream() + .filter(me -> mapMcb.getKeyFilter().test(keyConverter.fromString(me.getKey())) && !deletedKeys.contains(me.getKey())) + .map(Map.Entry::getValue) + .filter(mapMcb.getEntityFilter()) + // snapshot list + .collect(Collectors.toList()).stream(); + + List ldapObjects = identityStore.fetchQueryResults(ldapQuery); + + ldapStream = ldapObjects.stream().map(ldapMapObject -> { + // we might have fetch client and realm users at the same time, now try to decode what is what + LdapMapUserEntityFieldDelegate entity = new LdapMapUserEntityFieldDelegate(new LdapUserEntity(ldapMapObject, userMapperConfig, this)); + LdapMapUserEntityFieldDelegate 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 (MapUserEntity) entity; + }) + .filter(Objects::nonNull) + .filter(me -> !deletedKeys.contains(me.getId())) + // re-apply filters about client users 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(); + + 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 LdapMapQuery getLdapQuery() { + 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 usersDn = ldapMapConfig.getUsersDn(); + ldapMapQuery.setSearchDn(usersDn); + + Collection userObjectClasses = ldapMapConfig.getUserObjectClasses(); + ldapMapQuery.addObjectClasses(userObjectClasses); + + String usersRdnAttr = userMapperConfig.getUserNameLdapAttribute(); + + ldapMapQuery.addReturningLdapAttribute(usersRdnAttr); + ldapMapQuery.addReturningLdapAttribute("sn"); + ldapMapQuery.addReturningLdapAttribute("cn"); + ldapMapQuery.addReturningLdapAttribute("mail"); + + ldapMapQuery.addReturningLdapAttribute(userMapperConfig.getMembershipLdapAttribute()); + userMapperConfig.getUserAttributes().forEach(ldapMapQuery::addReturningLdapAttribute); + return ldapMapQuery; + } + + @Override + public void commit() { + super.commit(); + delegate.commit(); + + for (MapTaskWithValue mapTaskWithValue : tasksOnCommit) { + mapTaskWithValue.execute(); + } + + entities.forEach((entityKey, entity) -> { + if (entity.isUpdated()) { + identityStore.update(entity.getLdapMapObject()); + } + }); + + // once the commit is complete, clear the local storage to avoid problems when rollback() is called later + // due to a different transaction failing. + tasksOnCommit.clear(); + entities.clear(); + tasksOnRollback.clear(); + } + + @Override + public void rollback() { + super.rollback(); + delegate.rollback(); + + Iterator iterator = tasksOnRollback.descendingIterator(); + while (iterator.hasNext()) { + iterator.next().execute(); + } + } + + protected LdapUserModelCriteriaBuilder createLdapModelCriteriaBuilder() { + return new LdapUserModelCriteriaBuilder(userMapperConfig); + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/LdapUserModelCriteriaBuilder.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/LdapUserModelCriteriaBuilder.java new file mode 100644 index 000000000000..b5c791a98d9e --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/LdapUserModelCriteriaBuilder.java @@ -0,0 +1,172 @@ +/* + * 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.user; + +import org.keycloak.models.ModelException; +import org.keycloak.models.UserModel; +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.user.config.LdapMapUserMapperConfig; +import org.keycloak.models.map.storage.ldap.user.entity.LdapUserEntity; +import org.keycloak.storage.SearchableModelField; + +import java.util.Objects; +import java.util.function.Supplier; + +public class LdapUserModelCriteriaBuilder extends LdapModelCriteriaBuilder { + + private final LdapMapUserMapperConfig userMapperConfig; + + public String getRealmId() { + return realmId; + } + + private String realmId; + + @Override + public LdapUserModelCriteriaBuilder and(LdapUserModelCriteriaBuilder... builders) { + LdapUserModelCriteriaBuilder and = super.and(builders); + for (LdapUserModelCriteriaBuilder builder : builders) { + 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 LdapUserModelCriteriaBuilder or(LdapUserModelCriteriaBuilder... builders) { + LdapUserModelCriteriaBuilder or = super.or(builders); + for (LdapUserModelCriteriaBuilder builder : builders) { + if (builder.realmId != null) { + throw new ModelException("realmId not supported in OR condition"); + } + } + return or; + } + + @Override + public LdapUserModelCriteriaBuilder not(LdapUserModelCriteriaBuilder builder) { + LdapUserModelCriteriaBuilder not = super.not(builder); + if (builder.realmId != null) { + throw new ModelException("realmId not supported in NOT condition"); + } + return not; + } + + public LdapUserModelCriteriaBuilder(LdapMapUserMapperConfig userMapperConfig) { + super(predicateFunc -> new LdapUserModelCriteriaBuilder(userMapperConfig, predicateFunc)); + this.userMapperConfig = userMapperConfig; + } + + private LdapUserModelCriteriaBuilder(LdapMapUserMapperConfig userMapperConfig, Supplier predicateFunc) { + super(pf -> new LdapUserModelCriteriaBuilder(userMapperConfig, pf), predicateFunc); + this.userMapperConfig = userMapperConfig; + } + + @Override + public LdapUserModelCriteriaBuilder compare(SearchableModelField modelField, Operator op, Object... value) { + switch (op) { + case EQ: + if (modelField.equals(UserModel.SearchableFields.REALM_ID)) { + LdapUserModelCriteriaBuilder result = new LdapUserModelCriteriaBuilder(userMapperConfig, StringBuilder::new); + result.realmId = (String) value[0]; + return result; + } else if (modelField.equals(UserModel.SearchableFields.CONSENT_FOR_CLIENT) || + modelField.equals(UserModel.SearchableFields.CONSENT_WITH_CLIENT_SCOPE) || + modelField.equals(UserModel.SearchableFields.ASSIGNED_ROLE) || + modelField.equals(UserModel.SearchableFields.ATTRIBUTE) || + modelField.equals(UserModel.SearchableFields.ASSIGNED_GROUP)) { + // TODO: don't check on this field in LDAP + return new LdapUserModelCriteriaBuilder(userMapperConfig, StringBuilder::new); + } else if (modelField.equals(UserModel.SearchableFields.USERNAME) || modelField.equals(UserModel.SearchableFields.EMAIL) + || modelField.equals(UserModel.SearchableFields.FIRST_NAME) || modelField.equals(UserModel.SearchableFields.LAST_NAME)) { + // validateValue(value, modelField, op, String.class); + String field = modelFieldNameToLdap(userMapperConfig, modelField); + return new LdapUserModelCriteriaBuilder(userMapperConfig, + () -> equal(field, value[0], LdapMapEscapeStrategy.DEFAULT, false)); + } else { + throw new CriterionNotSupportedException(modelField, op); + } + + case NE: + throw new CriterionNotSupportedException(modelField, op); + + case ILIKE: + case LIKE: + if (modelField.equals(UserModel.SearchableFields.USERNAME) || modelField.equals(UserModel.SearchableFields.EMAIL) + || modelField.equals(UserModel.SearchableFields.FIRST_NAME) || modelField.equals(UserModel.SearchableFields.LAST_NAME)) { + // 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(userMapperConfig, modelField); + return new LdapUserModelCriteriaBuilder(userMapperConfig, () -> { + 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: + throw new CriterionNotSupportedException(modelField, op); + + case NOT_EXISTS: + if (modelField.equals(UserModel.SearchableFields.SERVICE_ACCOUNT_CLIENT)) { + // TODO: don't check on this field in LDAP + return new LdapUserModelCriteriaBuilder(userMapperConfig, StringBuilder::new); + } else { + throw new CriterionNotSupportedException(modelField, op); + } + + default: + throw new CriterionNotSupportedException(modelField, op); + } + } + + private String modelFieldNameToLdap(LdapMapUserMapperConfig userMapperConfig, SearchableModelField modelField) { + if (modelField.equals(UserModel.SearchableFields.USERNAME)) { + return userMapperConfig.getUserNameLdapAttribute(); + } else if (modelField.equals(UserModel.SearchableFields.EMAIL)) { + return "mail"; + } else if (modelField.equals(UserModel.SearchableFields.FIRST_NAME)) { + return "cn"; + } else if (modelField.equals(UserModel.SearchableFields.LAST_NAME)) { + return "sn"; + } else { + throw new CriterionNotSupportedException(modelField, null); + } + } + + public LdapUserModelCriteriaBuilder withCustomFilter(String customFilter) { + if (customFilter != null && toString().length() > 0) { + return and(this, new LdapUserModelCriteriaBuilder(userMapperConfig, () -> new StringBuilder(customFilter))); + } + return this; + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/config/LdapMapUserMapperConfig.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/config/LdapMapUserMapperConfig.java new file mode 100644 index 000000000000..07e4be673007 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/config/LdapMapUserMapperConfig.java @@ -0,0 +1,120 @@ +/* + * 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.user.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 LdapMapUserMapperConfig extends LdapMapCommonGroupMapperConfig { + + private final Config.Scope config; + private final LdapMapConfig ldapMapConfig; + + public LdapMapUserMapperConfig(Config.Scope config) { + super(new ComponentModel() { + @Override + public MultivaluedHashMap getConfig() { + return new MultivaluedHashMap() { + @Override + public String getFirst(String key) { + return config.get(key); + } + }; + } + }); + this.config = config; + this.ldapMapConfig = new LdapMapConfig(config); + } + + public String getRealmUsersDn() { + String usersDn = config.get(REALM_USERS_DN); + if (usersDn == null) { + throw new ModelException("Users DN is null! Check your configuration"); + } + return usersDn; + } + + public String getCommonUsersDn() { + String usersDn = config.get(COMMON_USERS_DN); + if (usersDn == null) { + throw new ModelException("Users DN is null! Check your configuration"); + } + return usersDn; + } + + public String getClientUsersDn() { + String usersDn = config.get(CLIENT_USERS_DN); + if (usersDn == null) { + throw new ModelException("Users DN is null! Check your configuration"); + } + return usersDn; + } + + public Set getUserAttributes() { + String userAttributes = mapperModel.getConfig().getFirst("user.attributes"); + if (userAttributes == null) { + userAttributes = ""; + } + return new HashSet<>(Arrays.asList(userAttributes.trim().split("\\s+"))); + } + + // LDAP DN where are realm users of this tree saved. + public static final String REALM_USERS_DN = "users.realm.dn"; + + // LDAP DN where are client users of this tree saved. + public static final String CLIENT_USERS_DN = "users.client.dn"; + + // LDAP DN to find both client and realm users. + public static final String COMMON_USERS_DN = "users.common.dn"; + + // Customized LDAP filter which is added to the whole LDAP query + public static final String USERS_LDAP_FILTER = "users.ldap.filter"; + + // See UserUsersRetrieveStrategy + public static final String LOAD_USERS_BY_MEMBER_ATTRIBUTE = "LOAD_USERS_BY_MEMBER_ATTRIBUTE"; + public static final String GET_USERS_FROM_USER_MEMBEROF_ATTRIBUTE = "GET_USERS_FROM_USER_MEMBEROF_ATTRIBUTE"; + public static final String LOAD_USERS_BY_MEMBER_ATTRIBUTE_RECURSIVELY = "LOAD_USERS_BY_MEMBER_ATTRIBUTE_RECURSIVELY"; + + public String getUserNameLdapAttribute() { + return getLdapMapConfig().getUsernameLdapAttribute(); + } + + @Override + public String getLDAPGroupNameLdapAttribute() { + return getUserNameLdapAttribute(); + } + + public String getCustomLdapFilter() { + return mapperModel.getConfig().getFirst(USERS_LDAP_FILTER); + } + + public LdapMapConfig getLdapMapConfig() { + return ldapMapConfig; + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/credential/LdapSingleUserCredentialManagerEntity.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/credential/LdapSingleUserCredentialManagerEntity.java new file mode 100644 index 000000000000..67e957f4e831 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/credential/LdapSingleUserCredentialManagerEntity.java @@ -0,0 +1,78 @@ +/* + * 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.user.credential; + +import org.keycloak.credential.CredentialInput; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.map.credential.MapSubjectCredentialManagerEntity; +import org.keycloak.models.map.storage.ldap.model.LdapMapObject; +import org.keycloak.models.map.storage.ldap.user.LdapUserMapKeycloakTransaction; + +import javax.naming.AuthenticationException; +import java.util.List; +import java.util.stream.Stream; + +/** + * Adapter to forward calls to the Map storage API to LDAP. + * + * @author Alexander Schwartz + */ +public class LdapSingleUserCredentialManagerEntity implements MapSubjectCredentialManagerEntity { + private final LdapUserMapKeycloakTransaction transaction; + private final LdapMapObject user; + + public LdapSingleUserCredentialManagerEntity(LdapUserMapKeycloakTransaction transaction, LdapMapObject user) { + this.transaction = transaction; + this.user = user; + } + + @Override + public void validateCredentials(List inputs) { + inputs.removeIf(input -> { + try { + if (input instanceof UserCredentialModel && input.getType().equals(PasswordCredentialModel.TYPE)) { + transaction.getIdentityStore().validatePassword(user, input.getChallengeResponse()); + return true; + } + } catch(AuthenticationException ex) { + return false; + } + return false; + }); + } + + @Override + public boolean updateCredential(CredentialInput input) { + if (input instanceof UserCredentialModel && input.getType().equals(PasswordCredentialModel.TYPE)) { + transaction.getIdentityStore().updatePassword(user, ((UserCredentialModel) input).getValue(), null); + return true; + } + return false; + } + + @Override + public boolean isConfiguredFor(String type) { + return false; + } + + @Override + public Stream getDisableableCredentialTypesStream() { + return Stream.empty(); + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/entity/LdapMapUserEntityFieldDelegate.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/entity/LdapMapUserEntityFieldDelegate.java new file mode 100644 index 000000000000..639be159b180 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/entity/LdapMapUserEntityFieldDelegate.java @@ -0,0 +1,52 @@ +/* + * Copyright 2022. Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.ldap.user.entity; + +import org.keycloak.models.map.common.delegate.EntityFieldDelegate; +import org.keycloak.models.map.credential.MapSubjectCredentialManagerEntity; +import org.keycloak.models.map.user.MapUserEntity; +import org.keycloak.models.map.user.MapUserEntityFieldDelegate; +import org.keycloak.models.map.storage.ldap.model.LdapMapObject; + +public class LdapMapUserEntityFieldDelegate extends MapUserEntityFieldDelegate { + + public LdapMapUserEntityFieldDelegate(EntityFieldDelegate entityFieldDelegate) { + super(entityFieldDelegate); + } + + @Override + public LdapUserEntity getEntityFieldDelegate() { + return (LdapUserEntity) super.getEntityFieldDelegate(); + } + + @Override + public boolean isUpdated() { + // TODO: EntityFieldDelegate.isUpdated is broken, as it is never updated + return getEntityFieldDelegate().isUpdated(); + } + + @Override + public MapSubjectCredentialManagerEntity credentialManager() { + return getEntityFieldDelegate().getUserCredentialManager(); + } + + public LdapMapObject getLdapMapObject() { + return getEntityFieldDelegate().getLdapMapObject(); + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/entity/LdapUserEntity.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/entity/LdapUserEntity.java new file mode 100644 index 000000000000..e354d8574c78 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/entity/LdapUserEntity.java @@ -0,0 +1,313 @@ +/* + * 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.user.entity; + +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.credential.MapSubjectCredentialManagerEntity; +import org.keycloak.models.map.storage.ldap.config.LdapKerberosConfig; +import org.keycloak.models.map.storage.ldap.user.credential.LdapSingleUserCredentialManagerEntity; +import org.keycloak.models.map.user.MapUserEntity; +import org.keycloak.models.map.user.MapUserEntityFields; +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.user.LdapUserMapKeycloakTransaction; +import org.keycloak.models.map.storage.ldap.user.config.LdapMapUserMapperConfig; +import org.keycloak.models.map.user.MapUserEntityImpl; + +import java.util.ArrayList; +import java.util.Collections; +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; + +public class LdapUserEntity extends UpdatableEntity.Impl implements EntityFieldDelegate { + + private final LdapMapObject ldapMapObject; + private final LdapMapUserMapperConfig userMapperConfig; + private final LdapUserMapKeycloakTransaction transaction; + + private static final EnumMap> SETTERS = new EnumMap<>(MapUserEntityFields.class); + + static { + SETTERS.put(MapUserEntityFields.USERNAME, (e, v) -> e.setName((String) v)); + SETTERS.put(MapUserEntityFields.LAST_NAME, (e, v) -> e.setLdapAttribute("sn", (String) v)); + SETTERS.put(MapUserEntityFields.FIRST_NAME, (e, v) -> e.setLdapAttribute("cn", (String) v)); + SETTERS.put(MapUserEntityFields.EMAIL, (e, v) -> e.setLdapAttribute("mail", (String) v)); + SETTERS.put(MapUserEntityFields.ID, (e, v) -> e.setId((String) v)); + SETTERS.put(MapUserEntityFields.REALM_ID, (e, v) -> e.setRealmId((String) v)); + //noinspection unchecked + SETTERS.put(MapUserEntityFields.ATTRIBUTES, (e, v) -> e.setAttributes((Map>) v)); + } + + private static final EnumMap> GETTERS = new EnumMap<>(MapUserEntityFields.class); + static { + GETTERS.put(MapUserEntityFields.USERNAME, LdapUserEntity::getName); + GETTERS.put(MapUserEntityFields.LAST_NAME, (e) -> e.getLdapAttribute("sn")); + GETTERS.put(MapUserEntityFields.FIRST_NAME, (e) -> e.getLdapAttribute("cn")); + GETTERS.put(MapUserEntityFields.EMAIL, (e) -> e.getLdapAttribute("mail")); + GETTERS.put(MapUserEntityFields.ID, LdapUserEntity::getId); + GETTERS.put(MapUserEntityFields.REALM_ID, LdapUserEntity::getRealmId); + GETTERS.put(MapUserEntityFields.ATTRIBUTES, LdapUserEntity::getAttributes); + GETTERS.put(MapUserEntityFields.ENABLED, LdapUserEntity::isEnabled); + } + + // https://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx + // All enabled user objects + // (!(userAccountControl:1.2.840.113556.1.4.803:=2))) + // http://www.selfadsi.org/ads-attributes/user-userAccountControl.htm + private boolean isEnabled() { + return true; + } + + private static final EnumMap> ADDERS = new EnumMap<>(MapUserEntityFields.class); + static { + } + + private static final EnumMap> REMOVERS = new EnumMap<>(MapUserEntityFields.class); + static { + } + + private MapUserEntity delegate; + + public LdapUserEntity(DeepCloner cloner, LdapMapUserMapperConfig userMapperConfig, LdapUserMapKeycloakTransaction transaction) { + ldapMapObject = new LdapMapObject(); + ldapMapObject.setObjectClasses(userMapperConfig.getLdapMapConfig().getUserObjectClasses()); + ldapMapObject.setRdnAttributeName(userMapperConfig.getUserNameLdapAttribute()); + this.delegate = new MapUserEntityImpl(cloner); + this.userMapperConfig = userMapperConfig; + this.transaction = transaction; + } + + public LdapUserEntity(LdapMapObject ldapMapObject, LdapMapUserMapperConfig userMapperConfig, LdapUserMapKeycloakTransaction transaction) { + this.ldapMapObject = ldapMapObject; + this.userMapperConfig = userMapperConfig; + this.transaction = transaction; + MapUserEntity entity = transaction.getDelegate().read(ldapMapObject.getId()); + if (entity == null) { + entity = new MapUserEntityImpl(null); + } + this.delegate = entity; + } + + public String getId() { + return ldapMapObject.getId(); + } + + public void setId(String id) { + this.updated |= !Objects.equals(getId(), id); + ldapMapObject.setId(id); + delegate.setId(id); + } + + public void createDelegate() { + delegate.setId(ldapMapObject.getId()); + delegate = transaction.getDelegate().create(delegate); + } + + private void setLdapAttribute(String attributeName, String attributeValue) { + this.updated |= !Objects.equals(getLdapAttribute(attributeName), attributeValue); + ldapMapObject.setSingleAttribute(attributeName, attributeValue); + } + + private String getLdapAttribute(String attributeName) { + return ldapMapObject.getAttributeAsString(attributeName); + } + + public Map> getAttributes() { + Map> result = new HashMap<>(); + for (String userAttribute : userMapperConfig.getUserAttributes()) { + Set attrs = ldapMapObject.getAttributeAsSet(userAttribute); + if (attrs != null) { + result.put(userAttribute, new ArrayList<>(attrs)); + } + } + + // KERBEROS_PRINCIPAL is used by KerberosFederationProvider to figure out if the user returned by username really matches the Kerberos realm + result.put("KERBEROS_PRINCIPAL", Collections.singletonList(getName() + "@" + new LdapKerberosConfig(userMapperConfig.getLdapMapConfig()).getKerberosRealm())); + return result; + } + + public void setAttributes(Map> attributes) { + // store all attributes + if (attributes != null) { + attributes.forEach(this::setAttribute); + } + // clear attributes not in the list + for (String userAttribute : userMapperConfig.getUserAttributes()) { + if (attributes == null || !attributes.containsKey(userAttribute)) { + removeAttribute(userAttribute); + } + } + if (delegate.getAttributes() != null) { + for (String userAttribute : delegate.getAttributes().keySet()) { + if (attributes == null || !attributes.containsKey(userAttribute)) { + removeAttribute(userAttribute); + } + } + } + } + + public List getAttribute(String name) { + if (!userMapperConfig.getUserAttributes().contains(name)) { + return delegate.getAttribute(name); + } + return new ArrayList<>(ldapMapObject.getAttributeAsSet(name)); + } + + public void setAttribute(String name, List value) { + if (!userMapperConfig.getUserAttributes().contains(name)) { + delegate.setAttribute(name, value); + } + 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 (!userMapperConfig.getUserAttributes().contains(name)) { + delegate.removeAttribute(name); + } + 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 getName() { + return ldapMapObject.getAttributeAsString(userMapperConfig.getUserNameLdapAttribute()); + } + + public String getDescription() { + return ldapMapObject.getAttributeAsString("description"); + } + + public void setRealmId(String realmId) { + // we'll not store this information, as LDAP store might be used from different realms + } + + public void setName(String name) { + this.updated |= !Objects.equals(getName(), name); + ldapMapObject.setSingleAttribute(userMapperConfig.getUserNameLdapAttribute(), name); + LdapMapDn dn = LdapMapDn.fromString(userMapperConfig.getLdapMapConfig().getUsersDn()); + dn.addFirst(userMapperConfig.getUserNameLdapAttribute(), name); + ldapMapObject.setDn(dn); + } + + public LdapMapObject getLdapMapObject() { + return ldapMapObject; + } + + @Override + public > & EntityField> void set(EF field, T value) { + BiConsumer consumer = SETTERS.get(field); + if (consumer == null) { + field.set(delegate, value); + } else { + consumer.accept(this, value); + } + } + + @Override + public boolean isUpdated() { + return super.isUpdated() || delegate.isUpdated(); + } + + @Override + public > & EntityField> void collectionAdd(EF field, T value) { + BiConsumer consumer = ADDERS.get(field); + if (consumer == null) { + field.collectionAdd(delegate, value); + } else { + consumer.accept(this, value); + } + } + + @Override + public > & EntityField> Object collectionRemove(EF field, T value) { + BiFunction consumer = REMOVERS.get(field); + if (consumer == null) { + return field.collectionRemove(delegate, value); + } else { + return consumer.apply(this, value); + } + } + + @Override + public > & EntityField> Object get(EF field) { + Function consumer = GETTERS.get(field); + if (consumer == null) { + return field.get(delegate); + } else { + return consumer.apply(this); + } + } + + @Override + public > & EntityField> Object mapGet(EF field, K key) { + if (field == MapUserEntityFields.ATTRIBUTES) { + return getAttribute((String) key); + } else { + throw new ModelException("unsupported field for mapGet " + field); + } + } + + @Override + public > & EntityField> void mapPut(EF field, K key, T value) { + if (field == MapUserEntityFields.ATTRIBUTES) { + //noinspection unchecked + setAttribute((String) key, (List) value); + } else { + throw new ModelException("unsupported field for mapGetPut " + field); + } + } + + @Override + public > & EntityField> Object mapRemove(EF field, K key) { + if (field == MapUserEntityFields.ATTRIBUTES) { + removeAttribute((String) key); + return null; + } else { + throw new ModelException("unsupported field for mapRemove " + field); + } + } + + public MapSubjectCredentialManagerEntity getUserCredentialManager() { + return new LdapSingleUserCredentialManagerEntity(transaction, ldapMapObject); + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/CommonKerberosConfig.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/CommonKerberosConfig.java new file mode 100644 index 000000000000..a23f38e049a7 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/CommonKerberosConfig.java @@ -0,0 +1,64 @@ +/* + * 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.user.kerberos; + +import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.component.ComponentModel; +import org.keycloak.representations.idm.ComponentRepresentation; + +/** + * Common configuration useful for all providers + * + * @author Marek Posolda + */ +public abstract class CommonKerberosConfig { + + protected MultivaluedHashMap userStorageConfig; + + public CommonKerberosConfig(MultivaluedHashMap userStorageConfig) { + this.userStorageConfig = userStorageConfig; + } + + protected MultivaluedHashMap getConfig() { + return userStorageConfig; + } + + // Should be always true for KerberosFederationProvider + public boolean isAllowKerberosAuthentication() { + return Boolean.valueOf(getConfig().getFirst(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION)); + } + + public String getKerberosRealm() { + return getConfig().getFirst(KerberosConstants.KERBEROS_REALM); + } + + public String getServerPrincipal() { + return getConfig().getFirst(KerberosConstants.SERVER_PRINCIPAL); + } + + public String getKeyTab() { + return getConfig().getFirst(KerberosConstants.KEYTAB); + } + + public boolean isDebug() { + return Boolean.valueOf(getConfig().getFirst(KerberosConstants.DEBUG)); + } + + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/KerberosServerSubjectAuthenticator.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/KerberosServerSubjectAuthenticator.java new file mode 100644 index 000000000000..085c516bc0aa --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/KerberosServerSubjectAuthenticator.java @@ -0,0 +1,81 @@ +/* + * 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.user.kerberos.impl; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.KerberosJdkProvider; +import org.keycloak.models.map.storage.ldap.user.kerberos.CommonKerberosConfig; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import java.io.IOException; + +/** + * @author Marek Posolda + */ +public class KerberosServerSubjectAuthenticator { + + private static final Logger logger = Logger.getLogger(KerberosServerSubjectAuthenticator.class); + + private static final CallbackHandler NO_CALLBACK_HANDLER = new CallbackHandler() { + + @Override + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + throw new UnsupportedCallbackException(callbacks[0]); + } + }; + + + private final CommonKerberosConfig config; + private LoginContext loginContext; + + + public KerberosServerSubjectAuthenticator(CommonKerberosConfig config) { + this.config = config; + } + + + public Subject authenticateServerSubject() throws LoginException { + Configuration config = createJaasConfiguration(); + loginContext = new LoginContext("does-not-matter", null, NO_CALLBACK_HANDLER, config); + loginContext.login(); + return loginContext.getSubject(); + } + + + public void logoutServerSubject() { + if (loginContext != null) { + try { + loginContext.logout(); + } catch (LoginException le) { + logger.error("Failed to logout kerberos server subject: " + config.getServerPrincipal(), le); + } + } + } + + + protected Configuration createJaasConfiguration() { + return KerberosJdkProvider.getProvider().createJaasConfigurationForServer(config.getKeyTab(), config.getServerPrincipal(), config.isDebug()); + } + +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/KerberosUsernamePasswordAuthenticator.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/KerberosUsernamePasswordAuthenticator.java new file mode 100644 index 000000000000..6f4b3d869704 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/KerberosUsernamePasswordAuthenticator.java @@ -0,0 +1,186 @@ +/* + * 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.user.kerberos.impl; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.KerberosJdkProvider; +import org.keycloak.models.ModelException; +import org.keycloak.models.map.storage.ldap.user.kerberos.CommonKerberosConfig; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import java.io.IOException; + +/** + * @author Marek Posolda + */ +public class KerberosUsernamePasswordAuthenticator { + + private static final Logger logger = Logger.getLogger(KerberosUsernamePasswordAuthenticator.class); + + protected final CommonKerberosConfig config; + private LoginContext loginContext; + + public KerberosUsernamePasswordAuthenticator(CommonKerberosConfig config) { + this.config = config; + } + + + /** + * Returns true if user with given username exists in kerberos database + * + * @param username username without Kerberos realm attached or with correct realm attached + * @return true if user available + */ + public boolean isUserAvailable(String username) { + logger.debugf("Checking existence of user: %s", username); + try { + String principal = getKerberosPrincipal(username); + loginContext = new LoginContext("does-not-matter", null, + createJaasCallbackHandler(principal, "fake-password-which-nobody-has"), + createJaasConfiguration()); + + loginContext.login(); + + throw new IllegalStateException("Didn't expect to end here"); + } catch (LoginException le) { + String message = le.getMessage(); + logger.debugf("Message from kerberos: %s", message); + + checkKerberosServerAvailable(le); + + // Bit cumbersome, but seems to work with tested kerberos servers + boolean exists = (!message.contains("Client not found")); + return exists; + } + } + + + /** + * Returns true if user was successfully authenticated against Kerberos + * + * @param username username without Kerberos realm attached or with correct realm attached + * @param password kerberos password + * @return true if user was successfully authenticated + */ + public boolean validUser(String username, String password) { + try { + authenticateSubject(username, password); + logoutSubject(); + return true; + } catch (LoginException le) { + checkKerberosServerAvailable(le); + + logger.debug("Failed to authenticate user " + username, le); + return false; + } + } + + protected void checkKerberosServerAvailable(LoginException le) { + String message = le.getMessage().toUpperCase(); + if (message.contains("PORT UNREACHABLE") || + message.contains("CANNOT LOCATE") || + message.contains("CANNOT CONTACT") || + message.contains("CANNOT FIND") || + message.contains("UNKNOWN ERROR")) { + throw new ModelException("Kerberos unreachable", le); + } + } + + + /** + * Returns true if user was successfully authenticated against Kerberos + * + * @param username username without Kerberos realm attached + * @param password kerberos password + * @return true if user was successfully authenticated + */ + public Subject authenticateSubject(String username, String password) throws LoginException { + String principal = getKerberosPrincipal(username); + + logger.debug("Validating password of principal: " + principal); + loginContext = new LoginContext("does-not-matter", null, + createJaasCallbackHandler(principal, password), + createJaasConfiguration()); + + loginContext.login(); + logger.debug("Principal " + principal + " authenticated succesfully"); + return loginContext.getSubject(); + } + + + public void logoutSubject() { + if (loginContext != null) { + try { + loginContext.logout(); + } catch (LoginException le) { + logger.error("Failed to logout kerberos subject", le); + } + } + } + + + protected String getKerberosPrincipal(String username) throws LoginException { + if (username.contains("@")) { + String[] tokens = username.split("@"); + + String kerberosRealm = tokens[1]; + if (!kerberosRealm.toUpperCase().equals(config.getKerberosRealm())) { + logger.warn("Invalid kerberos realm. Expected realm: " + config.getKerberosRealm() + ", username: " + username); + throw new LoginException("Client not found"); + } + + username = tokens[0]; + } + + return username + "@" + config.getKerberosRealm(); + } + + + protected CallbackHandler createJaasCallbackHandler(final String principal, final String password) { + return new CallbackHandler() { + + @Override + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof NameCallback) { + NameCallback nameCallback = (NameCallback) callback; + nameCallback.setName(principal); + } else if (callback instanceof PasswordCallback) { + PasswordCallback passwordCallback = (PasswordCallback) callback; + passwordCallback.setPassword(password.toCharArray()); + } else { + throw new UnsupportedCallbackException(callback, "Unsupported callback: " + callback.getClass().getCanonicalName()); + } + } + } + }; + } + + + protected Configuration createJaasConfiguration() { + return KerberosJdkProvider.getProvider().createJaasConfigurationForUsernamePasswordLogin(config.isDebug()); + } +} diff --git a/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/SPNEGOAuthenticator.java b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/SPNEGOAuthenticator.java new file mode 100644 index 000000000000..c16f80a1c760 --- /dev/null +++ b/model/map-ldap/src/main/java/org/keycloak/models/map/storage/ldap/user/kerberos/impl/SPNEGOAuthenticator.java @@ -0,0 +1,199 @@ +/* + * 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.user.kerberos.impl; + +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.Oid; +import org.jboss.logging.Logger; +import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.common.util.Base64; +import org.keycloak.common.util.KerberosSerializationUtils; +import org.keycloak.models.map.storage.ldap.user.kerberos.CommonKerberosConfig; + +import javax.security.auth.Subject; +import javax.security.auth.kerberos.KerberosTicket; +import java.io.IOException; +import java.security.PrivilegedExceptionAction; +import java.util.Iterator; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public class SPNEGOAuthenticator { + + private static final Logger log = Logger.getLogger(SPNEGOAuthenticator.class); + + private final KerberosServerSubjectAuthenticator kerberosSubjectAuthenticator; + private final String spnegoToken; + private final CommonKerberosConfig kerberosConfig; + + private boolean authenticated = false; + private String authenticatedKerberosPrincipal = null; + private GSSCredential delegationCredential; + private KerberosTicket kerberosTicket; + private String responseToken = null; + + public SPNEGOAuthenticator(CommonKerberosConfig kerberosConfig, KerberosServerSubjectAuthenticator kerberosSubjectAuthenticator, String spnegoToken) { + this.kerberosConfig = kerberosConfig; + this.kerberosSubjectAuthenticator = kerberosSubjectAuthenticator; + this.spnegoToken = spnegoToken; + } + + public void authenticate() { + if (log.isTraceEnabled()) { + log.trace("SPNEGO Login with token: " + spnegoToken); + } + + try { + Subject serverSubject = kerberosSubjectAuthenticator.authenticateServerSubject(); + authenticated = Subject.doAs(serverSubject, new AcceptSecContext()); + + // kerberosTicketis available in IBM JDK in case that GSSContext supports delegated credentials + Set kerberosTickets = serverSubject.getPrivateCredentials(KerberosTicket.class); + Iterator iterator = kerberosTickets.iterator(); + if (iterator.hasNext()) { + kerberosTicket = iterator.next(); + } + + } catch (Exception e) { + log.warn("SPNEGO login failed", e); + } finally { + kerberosSubjectAuthenticator.logoutServerSubject(); + } + } + + public boolean isAuthenticated() { + return authenticated; + } + + public String getResponseToken() { + return responseToken; + } + + public String getSerializedDelegationCredential() { + if (delegationCredential == null) { + if (log.isTraceEnabled()) { + log.trace("No delegation credential available."); + } + + return null; + } + + try { + if (log.isTraceEnabled()) { + log.trace("Serializing credential " + delegationCredential); + } + return KerberosSerializationUtils.serializeCredential(kerberosTicket, delegationCredential); + } catch (KerberosSerializationUtils.KerberosSerializationException kse) { + log.warn("Couldn't serialize credential: " + delegationCredential, kse); + return null; + } + } + + /** + * @return username to be used in Keycloak. Username is authenticated kerberos principal without realm name + */ + public String getAuthenticatedUsername() { + String[] tokens = authenticatedKerberosPrincipal.split("@"); + return tokens[0]; + } + + /** + * @return username to be used in Keycloak. Username is authenticated kerberos principal without realm name + */ + public String getKerberosRealm() { + String[] tokens = authenticatedKerberosPrincipal.split("@"); + return tokens[1]; + } + + + private class AcceptSecContext implements PrivilegedExceptionAction { + + @Override + public Boolean run() throws Exception { + GSSContext gssContext = null; + try { + if (log.isTraceEnabled()) { + log.trace("Going to establish security context"); + } + + gssContext = establishContext(); + logAuthDetails(gssContext); + + if (gssContext.isEstablished()) { + if (gssContext.getSrcName() == null) { + log.warn("GSS Context accepted, but no context initiator recognized. Check your kerberos configuration and reverse DNS lookup configuration"); + return false; + } + + authenticatedKerberosPrincipal = gssContext.getSrcName().toString(); + + if (gssContext.getCredDelegState()) { + delegationCredential = gssContext.getDelegCred(); + } + + return true; + } else { + return false; + } + } finally { + if (gssContext != null) { + gssContext.dispose(); + } + } + } + + } + + + protected GSSContext establishContext() throws GSSException, IOException { + GSSManager manager = GSSManager.getInstance(); + + Oid[] supportedMechs = new Oid[] { KerberosConstants.KRB5_OID, KerberosConstants.SPNEGO_OID }; + GSSCredential gssCredential = manager.createCredential(null, GSSCredential.INDEFINITE_LIFETIME, supportedMechs, GSSCredential.ACCEPT_ONLY); + GSSContext gssContext = manager.createContext(gssCredential); + + byte[] inputToken = Base64.decode(spnegoToken); + byte[] respToken = gssContext.acceptSecContext(inputToken, 0, inputToken.length); + responseToken = Base64.encodeBytes(respToken); + + return gssContext; + } + + + protected void logAuthDetails(GSSContext gssContext) throws GSSException { + if (log.isDebugEnabled()) { + String message = new StringBuilder("SPNEGO Security context accepted with token: " + responseToken) + .append(", established: ").append(gssContext.isEstablished()) + .append(", credDelegState: ").append(gssContext.getCredDelegState()) + .append(", mutualAuthState: ").append(gssContext.getMutualAuthState()) + .append(", lifetime: ").append(gssContext.getLifetime()) + .append(", confState: ").append(gssContext.getConfState()) + .append(", integState: ").append(gssContext.getIntegState()) + .append(", srcName: ").append(gssContext.getSrcName()) + .append(", targName: ").append(gssContext.getTargName()) + .toString(); + log.debug(message); + } + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/user/MapCredentialValidationOutput.java b/model/map/src/main/java/org/keycloak/models/map/user/MapCredentialValidationOutput.java index 468909c3fc43..e0429c1af72a 100644 --- a/model/map/src/main/java/org/keycloak/models/map/user/MapCredentialValidationOutput.java +++ b/model/map/src/main/java/org/keycloak/models/map/user/MapCredentialValidationOutput.java @@ -40,8 +40,8 @@ public MapCredentialValidationOutput(V authenticatedUser, CredentialValidationOu this.state = state; } - public static MapCredentialValidationOutput failed() { - return new MapCredentialValidationOutput(null, CredentialValidationOutput.Status.FAILED, Collections.emptyMap()); + public static MapCredentialValidationOutput failed() { + return new MapCredentialValidationOutput<>(null, CredentialValidationOutput.Status.FAILED, Collections.emptyMap()); } public V getAuthenticatedUser() { diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AbstractAccountPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AbstractAccountPage.java index 9b0bd768043a..d4abd00c6806 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AbstractAccountPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AbstractAccountPage.java @@ -18,9 +18,12 @@ package org.keycloak.testsuite.pages; import org.openqa.selenium.By; +import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.FindBy; +import static org.jboss.arquillian.graphene.Graphene.waitModel; + /** * @author Stian Thorgersen */ @@ -37,6 +40,12 @@ public abstract class AbstractAccountPage extends AbstractPage { public void logout() { logoutLink.click(); + try { + // wait until logout is complete + waitModel().until().element(By.linkText("Sign Out")).is().not().present(); + } catch (TimeoutException ex) { + throw new TimeoutException("found text: " + driver.findElement(By.tagName("header")).getText(), ex); + } } public String getLanguageDropdownText() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/test-krb5.conf b/testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/test-krb5.conf index 6ca62ce4d8e1..280abaa16996 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/test-krb5.conf +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/test-krb5.conf @@ -9,6 +9,7 @@ dns_canonicalize_hostname = false ignore_acceptor_hostname = true forwardable = true + qualify_shortname = "" [realms] KEYCLOAK.ORG = { diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/LdapMapStorage.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/LdapMapStorage.java index 0b65690b5fe5..d3a05a4a0301 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/LdapMapStorage.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/LdapMapStorage.java @@ -17,6 +17,7 @@ package org.keycloak.testsuite.model.parameters; import com.google.common.collect.ImmutableSet; +import org.junit.rules.ExternalResource; import org.jboss.logging.Logger; import org.junit.runner.Description; import org.junit.runners.model.Statement; @@ -38,7 +39,13 @@ import org.keycloak.testsuite.util.LDAPRule; import org.keycloak.util.ldap.LDAPEmbeddedServer; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Set; +import javax.naming.NamingException; /** * @author Alexander Schwartz @@ -57,15 +64,34 @@ public class LdapMapStorage extends KeycloakModelParameters { private final LDAPRule ldapRule = new LDAPRule(); + /** + * Temporary folder for concurrent hashmap storage. + * The classic {@link org.junit.rules.TemporaryFolder} won't work here, as we'll need the folder already + * in {@link #updateConfig(Config)} that is executed too early for that rule as it hasn't been initialized there, yet. + */ + private final Path temporaryFolder; + public LdapMapStorage() { super(ALLOWED_SPIS, ALLOWED_FACTORIES); + try { + temporaryFolder = Files.createTempDirectory(Paths.get("target"), "mapstorage-"); + } catch (IOException e) { + throw new RuntimeException("can't create temporary folder", e); + } } @Override public void updateConfig(Config cf) { + if (!temporaryFolder.toFile().exists()) { + // temporary folder has been cleaned up after previous test + if (!temporaryFolder.toFile().mkdir()) { + throw new RuntimeException("can't create folder " + temporaryFolder); + } + } + cf.spi(MapStorageSpi.NAME) .provider(ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) - .config("dir", "${project.build.directory:target}"); + .config("dir", temporaryFolder.toString()); cf.spi(MapStorageSpi.NAME) .provider(LdapMapStorageProviderFactory.PROVIDER_ID) @@ -96,7 +122,7 @@ public void updateConfig(Config cf) { .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("user").config("map.storage.provider", LdapMapStorageProviderFactory.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) @@ -115,7 +141,40 @@ public void updateConfig(Config cf) { @Override public Statement classRule(Statement base, Description description) { - return ldapRule.apply(base, description); + base = ldapRule.apply(base, description); + + // The folder cleanup is a classRule, as otherwise the @After methods might not be able to clean up realm information + // as the rule will run before the @After steps. + base = new ExternalResource() { + @Override + protected void before() throws Throwable { + if (!temporaryFolder.toFile().exists()) { + // temporary folder has been cleaned up after previous test + if (!temporaryFolder.toFile().mkdir()) { + throw new RuntimeException("can't create folder " + temporaryFolder); + } + } + } + + @Override + protected void after() { + if (temporaryFolder.toFile().exists()) { + File[] files = temporaryFolder.toFile().listFiles(); + if (files != null) { + for (File file : files) { + if (!file.delete()) { + throw new RuntimeException("can't delete file " + file); + } + } + } + if (!temporaryFolder.toFile().delete()) { + throw new RuntimeException("can't delete folder " + temporaryFolder); + } + } + } + }.apply(base, description); + + return base; } } diff --git a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json index e954399268d8..0552ebc945c8 100755 --- a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json +++ b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json @@ -155,7 +155,7 @@ "uuidLDAPAttribute": "entryUUID", "userObjectClasses": "inetOrgPerson, organizationalPerson", "connectionUrl": "${keycloak.map.storage.ldap.connectionUrl:}", - "usersDn": "ou=People,dc=keycloak,dc=org", + "usersDn": "ou=Internal,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", @@ -164,10 +164,15 @@ "membership.ldap.attribute": "member", "role.name.ldap.attribute": "cn", "role.object.classes": "groupOfNames", - "role.attributes": "ou", + "role.attributes": "description", + "user.attributes": "description", "mode": "LDAP_ONLY", "use.realm.roles.mapping": "true", - "connectionPooling": "true" + "connectionPooling": "true", + "use.realm.roles.mapping": "true", + "kerberosRealm": "KEYCLOAK.ORG", + "serverPrincipal": "HTTP/[email protected]", + "keyTab": "${keycloak.map.storage.ldap.keyTab:testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/http.keytab}" } }, diff --git a/util/embedded-ldap/src/main/resources/kerberos/default-users.ldif b/util/embedded-ldap/src/main/resources/kerberos/default-users.ldif index 414119341bac..0d9293f73f99 100644 --- a/util/embedded-ldap/src/main/resources/kerberos/default-users.ldif +++ b/util/embedded-ldap/src/main/resources/kerberos/default-users.ldif @@ -9,6 +9,16 @@ objectClass: organizationalUnit objectClass: top ou: People +dn: ou=Internal,ou=People,dc=keycloak,dc=org +objectClass: organizationalUnit +objectClass: top +ou: Internal + +dn: ou=RealmRoles,dc=keycloak,dc=org +objectclass: top +objectclass: organizationalUnit +ou: RealmRoles + dn: uid=krbtgt,ou=People,dc=keycloak,dc=org objectClass: top objectClass: person