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 super UserModel> 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 super UserModel> 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