From eb1f31e9ddc3ad9a8de75872525ff27ade659555 Mon Sep 17 00:00:00 2001 From: Michal Hajas Date: Mon, 11 Jul 2022 08:54:39 +0200 Subject: [PATCH] Optimize user-client session relationship for HotRod storage Closes #12818 --- ...eHotRodEntityImplementationsProcessor.java | 5 +- .../map/storage/hotRod/HotRodMapStorage.java | 10 +- .../HotRodMapStorageProviderFactory.java | 33 +++ .../SingleUseObjectHotRodMapStorage.java | 15 +- .../hotRod/common/HotRodTypesUtils.java | 12 ++ ...ientSessionReferenceOnlyFieldDelegate.java | 47 +++++ ...otRodAuthenticatedClientSessionEntity.java | 75 +++++-- ...edClientSessionEntityDelegateProvider.java | 56 ++++++ ...enticatedClientSessionEntityReference.java | 47 +++++ .../userSession/HotRodUserSessionEntity.java | 19 +- .../HotRodUserSessionTransaction.java | 165 +++++++++++++++ .../src/main/resources/config/cacheConfig.xml | 3 +- .../src/main/resources/config/infinispan.xml | 3 +- .../models/map/storage/ModelEntityUtil.java | 3 + .../map/storage/chm/MapFieldPredicates.java | 2 +- .../userSession/MapUserSessionAdapter.java | 8 +- .../src/main/resources/hotrod/infinispan.xml | 3 +- ...rSessionClientSessionRelationshipTest.java | 143 +++++++++++++ .../session/UserSessionConcurrencyTest.java | 190 ++++++++++++++++++ 19 files changed, 799 insertions(+), 40 deletions(-) create mode 100644 model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/AuthenticatedClientSessionReferenceOnlyFieldDelegate.java create mode 100644 model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntityDelegateProvider.java create mode 100644 model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntityReference.java create mode 100644 model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodUserSessionTransaction.java create mode 100644 testsuite/model/src/test/java/org/keycloak/testsuite/model/session/HotRodUserSessionClientSessionRelationshipTest.java create mode 100644 testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionConcurrencyTest.java diff --git a/model/build-processor/src/main/java/org/keycloak/models/map/processor/GenerateHotRodEntityImplementationsProcessor.java b/model/build-processor/src/main/java/org/keycloak/models/map/processor/GenerateHotRodEntityImplementationsProcessor.java index 38769edd21..bda50ab436 100644 --- a/model/build-processor/src/main/java/org/keycloak/models/map/processor/GenerateHotRodEntityImplementationsProcessor.java +++ b/model/build-processor/src/main/java/org/keycloak/models/map/processor/GenerateHotRodEntityImplementationsProcessor.java @@ -89,7 +89,6 @@ public class GenerateHotRodEntityImplementationsProcessor extends AbstractGenera } } - private class HotRodGettersAndSettersDelegateGenerator implements Generator { private static final String ENTITY_VARIABLE = "hotRodEntity"; @@ -643,11 +642,13 @@ public class GenerateHotRodEntityImplementationsProcessor extends AbstractGenera " }"); // cache name + boolean isMethodCall = hotRodAnnotation.cacheName().contains("("); + String quotes = isMethodCall ? "" : "\""; pw.println(" @Override\n" + " public String getCacheName() {\n" + (hotRodAnnotation.cacheName().isEmpty() ? " return org.keycloak.models.map.storage.ModelEntityUtil.getModelName(" + hotRodAnnotation.modelClass() + ".class);\n" - : " return \"" + hotRodAnnotation.cacheName() + "\";\n") + + : " return " + quotes + hotRodAnnotation.cacheName() + quotes + ";\n") + " }"); // delegate provider diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/HotRodMapStorage.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/HotRodMapStorage.java index 3dbeb39af1..c5b1a3150a 100644 --- a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/HotRodMapStorage.java +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/HotRodMapStorage.java @@ -55,7 +55,7 @@ import static org.keycloak.models.map.common.ExpirationUtils.isExpired; import static org.keycloak.models.map.storage.hotRod.common.HotRodUtils.paginateQuery; import static org.keycloak.utils.StreamsUtil.closing; -public class HotRodMapStorage & AbstractEntity, M> implements MapStorage, ConcurrentHashMapCrudOperations { +public class HotRodMapStorage, M> implements MapStorage, ConcurrentHashMapCrudOperations { private static final Logger LOG = Logger.getLogger(HotRodMapStorage.class); @@ -223,10 +223,14 @@ public class HotRodMapStorage sessionTransaction = session.getAttribute("map-transaction-" + hashCode(), MapKeycloakTransaction.class); if (sessionTransaction == null) { - Map, MapModelCriteriaBuilder.UpdatePredicatesFunc> fieldPredicates = MapFieldPredicates.getPredicates((Class) storedEntityDescriptor.getModelTypeClass()); - sessionTransaction = new ConcurrentHashMapKeycloakTransaction<>(this, keyConverter, cloner, fieldPredicates); + sessionTransaction = createTransactionInternal(session); session.setAttribute("map-transaction-" + hashCode(), sessionTransaction); } return sessionTransaction; } + + protected MapKeycloakTransaction createTransactionInternal(KeycloakSession session) { + Map, MapModelCriteriaBuilder.UpdatePredicatesFunc> fieldPredicates = MapFieldPredicates.getPredicates((Class) storedEntityDescriptor.getModelTypeClass()); + return new ConcurrentHashMapKeycloakTransaction<>(this, keyConverter, cloner, fieldPredicates); + } } diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/HotRodMapStorageProviderFactory.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/HotRodMapStorageProviderFactory.java index f0cd23f5bf..b72805bf79 100644 --- a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/HotRodMapStorageProviderFactory.java +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/HotRodMapStorageProviderFactory.java @@ -54,6 +54,10 @@ import org.keycloak.models.map.realm.entity.MapRequiredCredentialEntity; import org.keycloak.models.map.realm.entity.MapWebAuthnPolicyEntity; import org.keycloak.models.map.role.MapRoleEntity; import org.keycloak.models.map.singleUseObject.MapSingleUseObjectEntity; +import org.keycloak.models.map.storage.MapKeycloakTransaction; +import org.keycloak.models.map.storage.chm.ConcurrentHashMapKeycloakTransaction; +import org.keycloak.models.map.storage.chm.MapFieldPredicates; +import org.keycloak.models.map.storage.chm.MapModelCriteriaBuilder; import org.keycloak.models.map.storage.hotRod.authSession.HotRodAuthenticationSessionEntityDelegate; import org.keycloak.models.map.storage.hotRod.authSession.HotRodRootAuthenticationSessionEntityDelegate; import org.keycloak.models.map.storage.hotRod.authorization.HotRodPermissionTicketEntityDelegate; @@ -96,8 +100,10 @@ import org.keycloak.models.map.storage.hotRod.user.HotRodUserConsentEntityDelega import org.keycloak.models.map.storage.hotRod.user.HotRodUserCredentialEntityDelegate; import org.keycloak.models.map.storage.hotRod.user.HotRodUserEntityDelegate; import org.keycloak.models.map.storage.hotRod.user.HotRodUserFederatedIdentityEntityDelegate; +import org.keycloak.models.map.storage.hotRod.userSession.HotRodAuthenticatedClientSessionEntity; import org.keycloak.models.map.storage.hotRod.userSession.HotRodAuthenticatedClientSessionEntityDelegate; import org.keycloak.models.map.storage.hotRod.userSession.HotRodUserSessionEntityDelegate; +import org.keycloak.models.map.storage.hotRod.userSession.HotRodUserSessionTransaction; import org.keycloak.models.map.user.MapUserConsentEntity; import org.keycloak.models.map.user.MapUserCredentialEntity; import org.keycloak.models.map.user.MapUserEntity; @@ -105,6 +111,7 @@ import org.keycloak.models.map.user.MapUserFederatedIdentityEntity; import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity; import org.keycloak.models.map.userSession.MapUserSessionEntity; import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.storage.SearchableModelField; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -114,6 +121,7 @@ public class HotRodMapStorageProviderFactory implements AmphibianProviderFactory public static final String PROVIDER_ID = "hotrod"; private final Map, HotRodMapStorage> storages = new ConcurrentHashMap<>(); + private static final Map, MapModelCriteriaBuilder.UpdatePredicatesFunc> clientSessionPredicates = MapFieldPredicates.basePredicates(HotRodAuthenticatedClientSessionEntity.ID); private final static DeepCloner CLONER = new DeepCloner.Builder() .constructor(MapRootAuthenticationSessionEntity.class, HotRodRootAuthenticationSessionEntityDelegate::new) @@ -174,6 +182,9 @@ public class HotRodMapStorageProviderFactory implements AmphibianProviderFactory } public & AbstractEntity, M> HotRodMapStorage getHotRodStorage(KeycloakSession session, Class modelType, MapStorageProviderFactory.Flag... flags) { + // We need to preload client session store before we load user session store to avoid recursive update of storages map + if (modelType == UserSessionModel.class) getHotRodStorage(session, AuthenticatedClientSessionModel.class, flags); + return storages.computeIfAbsent(modelType, c -> createHotRodStorage(session, modelType, flags)); } @@ -183,6 +194,28 @@ public class HotRodMapStorageProviderFactory implements AmphibianProviderFactory if (modelType == ActionTokenValueModel.class) { return new SingleUseObjectHotRodMapStorage(connectionProvider.getRemoteCache(entityDescriptor.getCacheName()), StringKeyConverter.StringKey.INSTANCE, entityDescriptor, CLONER); + } if (modelType == AuthenticatedClientSessionModel.class) { + return new HotRodMapStorage(connectionProvider.getRemoteCache(entityDescriptor.getCacheName()), + StringKeyConverter.StringKey.INSTANCE, + entityDescriptor, + CLONER) { + @Override + protected MapKeycloakTransaction createTransactionInternal(KeycloakSession session) { + return new ConcurrentHashMapKeycloakTransaction(this, keyConverter, cloner, clientSessionPredicates); + } + }; + } if (modelType == UserSessionModel.class) { + HotRodMapStorage clientSessionStore = getHotRodStorage(session, AuthenticatedClientSessionModel.class); + return new HotRodMapStorage(connectionProvider.getRemoteCache(entityDescriptor.getCacheName()), + StringKeyConverter.StringKey.INSTANCE, + entityDescriptor, + CLONER) { + @Override + protected MapKeycloakTransaction createTransactionInternal(KeycloakSession session) { + Map, MapModelCriteriaBuilder.UpdatePredicatesFunc> fieldPredicates = MapFieldPredicates.getPredicates((Class) storedEntityDescriptor.getModelTypeClass()); + return new HotRodUserSessionTransaction(this, keyConverter, cloner, fieldPredicates, clientSessionStore.createTransaction(session)); + } + }; } return new HotRodMapStorage<>(connectionProvider.getRemoteCache(entityDescriptor.getCacheName()), StringKeyConverter.StringKey.INSTANCE, entityDescriptor, CLONER); } diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/SingleUseObjectHotRodMapStorage.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/SingleUseObjectHotRodMapStorage.java index 64bd5ccc19..96f20eaeea 100644 --- a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/SingleUseObjectHotRodMapStorage.java +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/SingleUseObjectHotRodMapStorage.java @@ -61,17 +61,10 @@ public class SingleUseObjectHotRodMapStorage createTransaction(KeycloakSession session) { - MapKeycloakTransaction transaction = session.getAttribute("map-transaction-" + hashCode(), MapKeycloakTransaction.class); - - if (transaction == null) { - Map, MapModelCriteriaBuilder.UpdatePredicatesFunc> fieldPredicates = - MapFieldPredicates.getPredicates((Class) storedEntityDescriptor.getModelTypeClass()); - transaction = new SingleUseObjectKeycloakTransaction(this, keyConverter, cloner, fieldPredicates); - session.setAttribute("map-transaction-" + hashCode(), transaction); - } - - return transaction; + protected MapKeycloakTransaction createTransactionInternal(KeycloakSession session) { + Map, MapModelCriteriaBuilder.UpdatePredicatesFunc> fieldPredicates = + MapFieldPredicates.getPredicates((Class) storedEntityDescriptor.getModelTypeClass()); + return new SingleUseObjectKeycloakTransaction(this, keyConverter, cloner, fieldPredicates); } @Override diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/common/HotRodTypesUtils.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/common/HotRodTypesUtils.java index cd74e62eb4..1db4fc7bb0 100644 --- a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/common/HotRodTypesUtils.java +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/common/HotRodTypesUtils.java @@ -18,10 +18,14 @@ package org.keycloak.models.map.storage.hotRod.common; import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.map.common.DeepCloner; import org.keycloak.models.map.storage.hotRod.authSession.HotRodAuthenticationSessionEntity; import org.keycloak.models.map.storage.hotRod.realm.entity.HotRodLocalizationTexts; import org.keycloak.models.map.storage.hotRod.user.HotRodUserConsentEntity; import org.keycloak.models.map.storage.hotRod.user.HotRodUserFederatedIdentityEntity; +import org.keycloak.models.map.storage.hotRod.userSession.AuthenticatedClientSessionReferenceOnlyFieldDelegate; +import org.keycloak.models.map.storage.hotRod.userSession.HotRodAuthenticatedClientSessionEntityReference; +import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity; import java.util.HashMap; import java.util.List; @@ -145,4 +149,12 @@ public class HotRodTypesUtils { return hotRodLocalizationTexts; } + + public static HotRodAuthenticatedClientSessionEntityReference migrateMapAuthenticatedClientSessionEntityToHotRodAuthenticatedClientSessionEntityReference(MapAuthenticatedClientSessionEntity p0) { + return new HotRodAuthenticatedClientSessionEntityReference(p0.getClientId(), p0.getId()); + } + + public static MapAuthenticatedClientSessionEntity migrateHotRodAuthenticatedClientSessionEntityReferenceToMapAuthenticatedClientSessionEntity(HotRodAuthenticatedClientSessionEntityReference collectionItem) { + return DeepCloner.DUMB_CLONER.entityFieldDelegate(MapAuthenticatedClientSessionEntity.class, new AuthenticatedClientSessionReferenceOnlyFieldDelegate(collectionItem)); + } } diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/AuthenticatedClientSessionReferenceOnlyFieldDelegate.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/AuthenticatedClientSessionReferenceOnlyFieldDelegate.java new file mode 100644 index 0000000000..2c3657f7f6 --- /dev/null +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/AuthenticatedClientSessionReferenceOnlyFieldDelegate.java @@ -0,0 +1,47 @@ +/* + * 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.hotRod.userSession; + +import org.keycloak.models.map.common.EntityField; +import org.keycloak.models.map.common.delegate.EntityFieldDelegate; +import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity; +import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntityFields; + +public class AuthenticatedClientSessionReferenceOnlyFieldDelegate implements EntityFieldDelegate { + + private final HotRodAuthenticatedClientSessionEntityReference reference; + + public AuthenticatedClientSessionReferenceOnlyFieldDelegate(HotRodAuthenticatedClientSessionEntityReference reference) { + this.reference = reference; + } + + @Override + public boolean isUpdated() { + return false; + } + + @Override + public > & EntityField> Object get(EF field) { + switch ((MapAuthenticatedClientSessionEntityFields)field) { + case ID: return reference.getClientSessionId(); + case CLIENT_ID: return reference.getClientId(); + } + + return null; + } +} diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntity.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntity.java index 31869af741..fe4ddc3eb7 100644 --- a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntity.java +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntity.java @@ -21,59 +21,106 @@ import org.infinispan.protostream.GeneratedSchema; import org.infinispan.protostream.annotations.AutoProtoSchemaBuilder; import org.infinispan.protostream.annotations.ProtoDoc; import org.infinispan.protostream.annotations.ProtoField; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.map.annotations.GenerateHotRodEntityImplementation; import org.keycloak.models.map.annotations.IgnoreForEntityImplementationGenerator; +import org.keycloak.models.map.storage.hotRod.authorization.HotRodResourceServerEntity; import org.keycloak.models.map.storage.hotRod.common.AbstractHotRodEntity; import org.keycloak.models.map.storage.hotRod.common.CommonPrimitivesProtoSchemaInitializer; import org.keycloak.models.map.storage.hotRod.common.HotRodPair; import org.keycloak.models.map.storage.hotRod.common.UpdatableHotRodEntityDelegateImpl; import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity; +import org.keycloak.storage.SearchableModelField; import java.util.Set; @GenerateHotRodEntityImplementation( - implementInterface = "org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity" + implementInterface = "org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity", + inherits = "org.keycloak.models.map.storage.hotRod.userSession.HotRodAuthenticatedClientSessionEntity.AbstractHotRodAuthenticatedClientSessionEntityDelegate", + topLevelEntity = true, + modelClass = "org.keycloak.models.AuthenticatedClientSessionModel", + cacheName = "org.keycloak.models.map.storage.ModelEntityUtil.getModelName(org.keycloak.models.UserSessionModel.class)" // Use the same cache name as user-sessions ) +@ProtoDoc("schema-version: " + HotRodResourceServerEntity.VERSION) @ProtoDoc("@Indexed") public class HotRodAuthenticatedClientSessionEntity extends AbstractHotRodEntity { - @ProtoField(number = 1) - public String id; + @IgnoreForEntityImplementationGenerator + public static final int VERSION = 1; - @ProtoField(number = 2) - public String realmId; + @IgnoreForEntityImplementationGenerator + public static final SearchableModelField ID = new SearchableModelField<>("id", String.class); + + @AutoProtoSchemaBuilder( + includeClasses = { + HotRodAuthenticatedClientSessionEntity.class + }, + schemaFilePath = "proto/", + schemaPackageName = CommonPrimitivesProtoSchemaInitializer.HOT_ROD_ENTITY_PACKAGE, + dependsOn = {CommonPrimitivesProtoSchemaInitializer.class} + ) + public interface HotRodAuthenticatedClientSessionEntitySchema extends GeneratedSchema { + HotRodAuthenticatedClientSessionEntitySchema INSTANCE = new HotRodAuthenticatedClientSessionEntitySchemaImpl(); + } + + @ProtoField(number = 1) + public Integer entityVersion = VERSION; @ProtoDoc("@Field(index = Index.YES, store = Store.YES)") + @ProtoField(number = 2) + public String id; + @ProtoField(number = 3) - public String clientId; + public String realmId; @ProtoField(number = 4) - public String authMethod; + public String clientId; @ProtoField(number = 5) - public String redirectUri; + public String authMethod; @ProtoField(number = 6) - public Long timestamp; + public String redirectUri; @ProtoField(number = 7) - public Long expiration; + public Long timestamp; @ProtoField(number = 8) - public String action; + public Long expiration; @ProtoField(number = 9) - public Set> notes; + public String action; @ProtoField(number = 10) - public String currentRefreshToken; + public Set> notes; @ProtoField(number = 11) - public Integer currentRefreshTokenUseCount; + public String currentRefreshToken; @ProtoField(number = 12) + public Integer currentRefreshTokenUseCount; + + @ProtoField(number = 13) public Boolean offline; + public static abstract class AbstractHotRodAuthenticatedClientSessionEntityDelegate extends UpdatableHotRodEntityDelegateImpl implements MapAuthenticatedClientSessionEntity { + @Override + public void setId(String id) { + HotRodAuthenticatedClientSessionEntity entity = getHotRodEntity(); + if (entity.id != null) throw new IllegalStateException("Id cannot be changed"); + entity.id = id; + entity.updated |= id != null; + } + + @Override + public void setClientId(String clientId) { + HotRodAuthenticatedClientSessionEntity entity = getHotRodEntity(); + if (entity.clientId != null) throw new IllegalStateException("ClientId cannot be changed"); + entity.clientId = clientId; + entity.updated |= clientId != null; + } + } + @Override public boolean equals(Object o) { return HotRodAuthenticatedClientSessionEntityDelegate.entityEquals(this, o); diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntityDelegateProvider.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntityDelegateProvider.java new file mode 100644 index 0000000000..a9c588078d --- /dev/null +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntityDelegateProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.hotRod.userSession; + +import org.keycloak.models.ModelIllegalStateException; +import org.keycloak.models.map.common.EntityField; +import org.keycloak.models.map.common.delegate.DelegateProvider; +import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity; +import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntityFields; + +public abstract class HotRodAuthenticatedClientSessionEntityDelegateProvider implements DelegateProvider { + + private MapAuthenticatedClientSessionEntity fullClientSessionData; + private MapAuthenticatedClientSessionEntity idClientIdReferenceOnly; + + public HotRodAuthenticatedClientSessionEntityDelegateProvider(MapAuthenticatedClientSessionEntity idClientIdReferenceOnly) { + this.idClientIdReferenceOnly = idClientIdReferenceOnly; + } + + @Override + public MapAuthenticatedClientSessionEntity getDelegate(boolean isRead, Enum> field, Object... parameters) { + if (fullClientSessionData != null) return fullClientSessionData; + + if (isRead) { + switch ((MapAuthenticatedClientSessionEntityFields) field) { + case ID: + case CLIENT_ID: + return idClientIdReferenceOnly; + } + } + + fullClientSessionData = loadClientSessionFromDatabase(); + if (fullClientSessionData == null) { + throw new ModelIllegalStateException("Unable to retrieve client session data with id: " + idClientIdReferenceOnly.getId()); + } + + return fullClientSessionData; + } + + public abstract MapAuthenticatedClientSessionEntity loadClientSessionFromDatabase(); +} diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntityReference.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntityReference.java new file mode 100644 index 0000000000..2bd6bd85f6 --- /dev/null +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodAuthenticatedClientSessionEntityReference.java @@ -0,0 +1,47 @@ +/* + * 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.hotRod.userSession; + +import org.infinispan.protostream.annotations.ProtoDoc; +import org.infinispan.protostream.annotations.ProtoField; + +@ProtoDoc("@Indexed") +public class HotRodAuthenticatedClientSessionEntityReference { + + @ProtoDoc("@Field(index = Index.YES, store = Store.YES)") + @ProtoField(number = 1) + public String clientId; + + @ProtoField(number = 2) + public String clientSessionId; + + public HotRodAuthenticatedClientSessionEntityReference() {} + + public HotRodAuthenticatedClientSessionEntityReference(String clientId, String clientSessionId) { + this.clientId = clientId; + this.clientSessionId = clientSessionId; + } + + public String getClientId() { + return clientId; + } + + public String getClientSessionId() { + return clientSessionId; + } +} diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodUserSessionEntity.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodUserSessionEntity.java index 60339e745d..6e855e15dc 100644 --- a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodUserSessionEntity.java +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodUserSessionEntity.java @@ -24,14 +24,20 @@ import org.infinispan.protostream.annotations.ProtoField; import org.keycloak.models.UserSessionModel; import org.keycloak.models.map.annotations.GenerateHotRodEntityImplementation; import org.keycloak.models.map.annotations.IgnoreForEntityImplementationGenerator; +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.common.delegate.DelegateProvider; import org.keycloak.models.map.storage.hotRod.authorization.HotRodResourceServerEntity; import org.keycloak.models.map.common.UpdatableEntity; +import org.keycloak.models.map.storage.hotRod.client.HotRodClientEntityDelegate; import org.keycloak.models.map.storage.hotRod.common.AbstractHotRodEntity; import org.keycloak.models.map.storage.hotRod.common.CommonPrimitivesProtoSchemaInitializer; +import org.keycloak.models.map.storage.hotRod.common.HotRodEntityDelegate; import org.keycloak.models.map.storage.hotRod.common.HotRodStringPair; +import org.keycloak.models.map.storage.hotRod.common.HotRodTypesUtils; import org.keycloak.models.map.storage.hotRod.common.UpdatableHotRodEntityDelegateImpl; import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity; import org.keycloak.models.map.userSession.MapUserSessionEntity; +import org.keycloak.models.map.userSession.MapUserSessionEntityDelegate; import java.util.Collections; import java.util.Objects; @@ -54,7 +60,7 @@ public class HotRodUserSessionEntity extends AbstractHotRodEntity { @AutoProtoSchemaBuilder( includeClasses = { HotRodUserSessionEntity.class, - HotRodAuthenticatedClientSessionEntity.class, + HotRodAuthenticatedClientSessionEntityReference.class, }, schemaFilePath = "proto/", schemaPackageName = CommonPrimitivesProtoSchemaInitializer.HOT_ROD_ENTITY_PACKAGE, @@ -119,7 +125,7 @@ public class HotRodUserSessionEntity extends AbstractHotRodEntity { @ProtoDoc("@Field(index = Index.YES, store = Store.YES)") @ProtoField(number = 16) - public Set authenticatedClientSessions; + public Set authenticatedClientSessions; @ProtoDoc("@Field(index = Index.YES, store = Store.YES)") @ProtoField(number = 17) @@ -166,15 +172,18 @@ public class HotRodUserSessionEntity extends AbstractHotRodEntity { @Override public Optional getAuthenticatedClientSession(String clientUUID) { - Set acss = getHotRodEntity().authenticatedClientSessions; + Set acss = getHotRodEntity().authenticatedClientSessions; if (acss == null || acss.isEmpty()) return Optional.empty(); - return acss.stream().filter(acs -> Objects.equals(acs.clientId, clientUUID)).findFirst().map(HotRodAuthenticatedClientSessionEntityDelegate::new); + return acss.stream() + .filter(acs -> Objects.equals(acs.clientId, clientUUID)) + .findFirst() + .map(HotRodTypesUtils::migrateHotRodAuthenticatedClientSessionEntityReferenceToMapAuthenticatedClientSessionEntity); } @Override public Boolean removeAuthenticatedClientSession(String clientUUID) { - Set acss = getHotRodEntity().authenticatedClientSessions; + Set acss = getHotRodEntity().authenticatedClientSessions; boolean removed = acss != null && acss.removeIf(uc -> Objects.equals(uc.clientId, clientUUID)); getHotRodEntity().updated |= removed; return removed; diff --git a/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodUserSessionTransaction.java b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodUserSessionTransaction.java new file mode 100644 index 0000000000..f8178183d9 --- /dev/null +++ b/model/map-hot-rod/src/main/java/org/keycloak/models/map/storage/hotRod/userSession/HotRodUserSessionTransaction.java @@ -0,0 +1,165 @@ +/* + * 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.hotRod.userSession; + +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.common.StringKeyConverter; +import org.keycloak.models.map.common.delegate.SimpleDelegateProvider; +import org.keycloak.models.map.storage.MapKeycloakTransaction; +import org.keycloak.models.map.storage.QueryParameters; +import org.keycloak.models.map.storage.chm.ConcurrentHashMapCrudOperations; +import org.keycloak.models.map.storage.chm.ConcurrentHashMapKeycloakTransaction; +import org.keycloak.models.map.storage.chm.MapModelCriteriaBuilder; +import org.keycloak.models.map.storage.criteria.DefaultModelCriteria; +import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity; +import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntityDelegate; +import org.keycloak.models.map.userSession.MapUserSessionEntity; +import org.keycloak.models.map.userSession.MapUserSessionEntityDelegate; +import org.keycloak.storage.SearchableModelField; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.keycloak.models.map.storage.ModelCriteriaBuilder.Operator.IN; + +public class HotRodUserSessionTransaction extends ConcurrentHashMapKeycloakTransaction { + + private final MapKeycloakTransaction clientSessionTransaction; + + public HotRodUserSessionTransaction(ConcurrentHashMapCrudOperations map, + StringKeyConverter keyConverter, + DeepCloner cloner, + Map, MapModelCriteriaBuilder.UpdatePredicatesFunc> fieldPredicates, + MapKeycloakTransaction clientSessionTransaction + ) { + super(map, keyConverter, cloner, fieldPredicates); + this.clientSessionTransaction = clientSessionTransaction; + } + + @Override + public void commit() { + super.commit(); + clientSessionTransaction.commit(); + } + + private MapAuthenticatedClientSessionEntity wrapClientSessionEntityToClientSessionAwareDelegate(MapAuthenticatedClientSessionEntity d) { + return new MapAuthenticatedClientSessionEntityDelegate(new HotRodAuthenticatedClientSessionEntityDelegateProvider(d) { + @Override + public MapAuthenticatedClientSessionEntity loadClientSessionFromDatabase() { + return clientSessionTransaction.read(d.getId()); + } + }); + } + + private MapUserSessionEntity wrapUserSessionEntityToClientSessionAwareDelegate(MapUserSessionEntity entity) { + if (entity == null) return null; + + return new MapUserSessionEntityDelegate(new SimpleDelegateProvider<>(entity)) { + @Override + public Set getAuthenticatedClientSessions() { + Set clientSessions = super.getAuthenticatedClientSessions(); + return clientSessions == null ? null : clientSessions.stream() + .map(HotRodUserSessionTransaction.this::wrapClientSessionEntityToClientSessionAwareDelegate) + .collect(Collectors.toSet()); + } + + @Override + public Optional getAuthenticatedClientSession(String clientUUID) { + return super.getAuthenticatedClientSession(clientUUID) + .map(HotRodUserSessionTransaction.this::wrapClientSessionEntityToClientSessionAwareDelegate); + } + + @Override + public void addAuthenticatedClientSession(MapAuthenticatedClientSessionEntity clientSession) { + super.addAuthenticatedClientSession(clientSession); + clientSessionTransaction.create(clientSession); + } + + @Override + public Boolean removeAuthenticatedClientSession(String clientUUID) { + Optional clientSession = getAuthenticatedClientSession(clientUUID); + if (!clientSession.isPresent()) { + return false; + } + return super.removeAuthenticatedClientSession(clientUUID) && clientSessionTransaction.delete(clientSession.get().getId()); + } + + @Override + public void clearAuthenticatedClientSessions() { + Set clientSessions = super.getAuthenticatedClientSessions(); + if (clientSessions != null) { + clientSessionTransaction.delete(QueryParameters.withCriteria( + DefaultModelCriteria.criteria() + .compare(HotRodAuthenticatedClientSessionEntity.ID, IN, clientSessions.stream() + .map(MapAuthenticatedClientSessionEntity::getId)) + )); + } + super.clearAuthenticatedClientSessions(); + } + }; + } + + + @Override + public MapUserSessionEntity read(String sKey) { + return wrapUserSessionEntityToClientSessionAwareDelegate(super.read(sKey)); + } + + @Override + public Stream read(QueryParameters queryParameters) { + return super.read(queryParameters).map(this::wrapUserSessionEntityToClientSessionAwareDelegate); + } + + @Override + public MapUserSessionEntity create(MapUserSessionEntity value) { + return wrapUserSessionEntityToClientSessionAwareDelegate(super.create(value)); + } + + @Override + public boolean delete(String key) { + MapUserSessionEntity uSession = read(key); + Set clientSessions = uSession.getAuthenticatedClientSessions(); + if (clientSessions != null) { + clientSessionTransaction.delete(QueryParameters.withCriteria( + DefaultModelCriteria.criteria() + .compare(HotRodAuthenticatedClientSessionEntity.ID, IN, clientSessions.stream() + .map(MapAuthenticatedClientSessionEntity::getId)) + )); + } + + return super.delete(key); + } + + @Override + public long delete(QueryParameters queryParameters) { + clientSessionTransaction.delete(QueryParameters.withCriteria( + DefaultModelCriteria.criteria() + .compare(HotRodAuthenticatedClientSessionEntity.ID, IN, read(queryParameters) + .flatMap(userSession -> Optional.ofNullable(userSession.getAuthenticatedClientSessions()).orElse(Collections.emptySet()).stream().map(AbstractEntity::getId))) + )); + + return super.delete(queryParameters); + } +} diff --git a/model/map-hot-rod/src/main/resources/config/cacheConfig.xml b/model/map-hot-rod/src/main/resources/config/cacheConfig.xml index 71337664cb..4e22782a0e 100644 --- a/model/map-hot-rod/src/main/resources/config/cacheConfig.xml +++ b/model/map-hot-rod/src/main/resources/config/cacheConfig.xml @@ -75,8 +75,9 @@ kc.HotRodUserSessionEntity - kc.HotRodAuthenticatedClientSessionEntity + kc.HotRodAuthenticatedClientSessionEntityReference kc.HotRodStringPair + kc.HotRodAuthenticatedClientSessionEntity diff --git a/model/map-hot-rod/src/main/resources/config/infinispan.xml b/model/map-hot-rod/src/main/resources/config/infinispan.xml index b3dbecf894..5a62a8c3c9 100644 --- a/model/map-hot-rod/src/main/resources/config/infinispan.xml +++ b/model/map-hot-rod/src/main/resources/config/infinispan.xml @@ -77,8 +77,9 @@ kc.HotRodUserSessionEntity - kc.HotRodAuthenticatedClientSessionEntity + kc.HotRodAuthenticatedClientSessionEntityReference kc.HotRodStringPair + kc.HotRodAuthenticatedClientSessionEntity diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/ModelEntityUtil.java b/model/map/src/main/java/org/keycloak/models/map/storage/ModelEntityUtil.java index 4b03042f30..71cf6d4278 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/ModelEntityUtil.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/ModelEntityUtil.java @@ -23,6 +23,7 @@ import org.keycloak.authorization.model.ResourceServer; import org.keycloak.events.Event; import org.keycloak.events.admin.AdminEvent; import org.keycloak.models.ActionTokenValueModel; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.GroupModel; @@ -48,6 +49,7 @@ import org.keycloak.models.map.loginFailure.MapUserLoginFailureEntity; import org.keycloak.models.map.realm.MapRealmEntity; import org.keycloak.models.map.role.MapRoleEntity; import org.keycloak.models.map.user.MapUserEntity; +import org.keycloak.models.map.userSession.MapAuthenticatedClientSessionEntity; import org.keycloak.models.map.userSession.MapUserSessionEntity; import org.keycloak.sessions.RootAuthenticationSessionModel; import java.util.HashMap; @@ -100,6 +102,7 @@ public class ModelEntityUtil { MODEL_TO_ENTITY_TYPE.put(UserLoginFailureModel.class, MapUserLoginFailureEntity.class); MODEL_TO_ENTITY_TYPE.put(UserModel.class, MapUserEntity.class); MODEL_TO_ENTITY_TYPE.put(UserSessionModel.class, MapUserSessionEntity.class); + MODEL_TO_ENTITY_TYPE.put(AuthenticatedClientSessionModel.class, MapAuthenticatedClientSessionEntity.class); // authz MODEL_TO_ENTITY_TYPE.put(PermissionTicket.class, MapPermissionTicketEntity.class); diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapFieldPredicates.java b/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapFieldPredicates.java index d0f12f8bb4..ee288f2e14 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapFieldPredicates.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/chm/MapFieldPredicates.java @@ -535,7 +535,7 @@ public class MapFieldPredicates { return mcb.fieldCompare(Boolean.TRUE::equals, getter); } - protected static Map, UpdatePredicatesFunc> basePredicates(SearchableModelField idField) { + public static Map, UpdatePredicatesFunc> basePredicates(SearchableModelField idField) { Map, UpdatePredicatesFunc> fieldPredicates = new HashMap<>(); fieldPredicates.put(idField, MapModelCriteriaBuilder::idCompare); return fieldPredicates; diff --git a/model/map/src/main/java/org/keycloak/models/map/userSession/MapUserSessionAdapter.java b/model/map/src/main/java/org/keycloak/models/map/userSession/MapUserSessionAdapter.java index d3b729d875..8dbfc88d27 100644 --- a/model/map/src/main/java/org/keycloak/models/map/userSession/MapUserSessionAdapter.java +++ b/model/map/src/main/java/org/keycloak/models/map/userSession/MapUserSessionAdapter.java @@ -20,6 +20,7 @@ import org.keycloak.common.util.Time; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelIllegalStateException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; @@ -144,7 +145,12 @@ public class MapUserSessionAdapter extends AbstractUserSessionModel { } public boolean filterAndRemoveExpiredClientSessions(MapAuthenticatedClientSessionEntity clientSession) { - if (isExpired(clientSession, false)) { + try { + if (isExpired(clientSession, false)) { + entity.removeAuthenticatedClientSession(clientSession.getClientId()); + return false; + } + } catch (ModelIllegalStateException ex) { entity.removeAuthenticatedClientSession(clientSession.getClientId()); return false; } diff --git a/testsuite/model/src/main/resources/hotrod/infinispan.xml b/testsuite/model/src/main/resources/hotrod/infinispan.xml index 0b9037d2ca..400a20419e 100644 --- a/testsuite/model/src/main/resources/hotrod/infinispan.xml +++ b/testsuite/model/src/main/resources/hotrod/infinispan.xml @@ -73,8 +73,9 @@ kc.HotRodUserSessionEntity - kc.HotRodAuthenticatedClientSessionEntity + kc.HotRodAuthenticatedClientSessionEntityReference kc.HotRodStringPair + kc.HotRodAuthenticatedClientSessionEntity diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/HotRodUserSessionClientSessionRelationshipTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/HotRodUserSessionClientSessionRelationshipTest.java new file mode 100644 index 0000000000..557021cb35 --- /dev/null +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/HotRodUserSessionClientSessionRelationshipTest.java @@ -0,0 +1,143 @@ +/* + * 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.testsuite.model.session; + +import org.infinispan.client.hotrod.RemoteCache; +import org.junit.Test; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.models.map.storage.ModelEntityUtil; +import org.keycloak.models.map.storage.hotRod.connections.DefaultHotRodConnectionProviderFactory; +import org.keycloak.models.map.storage.hotRod.connections.HotRodConnectionProvider; +import org.keycloak.models.map.storage.hotRod.userSession.HotRodUserSessionEntity; +import org.keycloak.testsuite.model.KeycloakModelTest; +import org.keycloak.testsuite.model.RequireProvider; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.containsInAnyOrder; + +@RequireProvider(UserSessionProvider.class) +@RequireProvider(value = HotRodConnectionProvider.class, only = DefaultHotRodConnectionProviderFactory.PROVIDER_ID) +public class HotRodUserSessionClientSessionRelationshipTest extends KeycloakModelTest { + + private String realmId; + private String CLIENT0_CLIENT_ID = "client0"; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().createRealm("test"); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + realm.setSsoSessionIdleTimeout(1800); + realm.setSsoSessionMaxLifespan(36000); + this.realmId = realm.getId(); + s.clients().addClient(realm, CLIENT0_CLIENT_ID); + + s.users().addUser(realm, "user1").setEmail("user1@localhost"); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + if (realmId != null) { + s.realms().removeRealm(realmId); + } + } + + @Test + public void testClientSessionAreRemovedOnUserSessionRemoval() { + AtomicReference uSessionId = new AtomicReference<>(); + AtomicReference cSessionId = new AtomicReference<>(); + prepareSessions(uSessionId, cSessionId); + + withRealm(realmId, (session, realm) -> { + UserSessionModel uSession = session.sessions().getUserSession(realm, uSessionId.get()); + session.sessions().removeUserSession(realm, uSession); + return null; + }); + + assertCacheContains(remoteCache -> assertThat(remoteCache, anEmptyMap())); + } + + @Test + public void testSessionsAreRemovedOnUserRemoval() { + AtomicReference uSessionId = new AtomicReference<>(); + AtomicReference cSessionId = new AtomicReference<>(); + prepareSessions(uSessionId, cSessionId); + + withRealm(realmId, (session, realm) -> { + session.users().removeUser(realm, session.users().getUserByUsername(realm, "user1")); + return null; + }); + + assertCacheContains(remoteCache -> { + assertThat(remoteCache, anEmptyMap()); + }); + } + + @Test + public void testSessionsAreRemovedOnRealmRemoval() { + AtomicReference uSessionId = new AtomicReference<>(); + AtomicReference cSessionId = new AtomicReference<>(); + prepareSessions(uSessionId, cSessionId); + + withRealm(realmId, (session, realm) -> { + session.realms().removeRealm(realm.getId()); + return null; + }); + + assertCacheContains(remoteCache -> { + assertThat(remoteCache, anEmptyMap()); + }); + } + + private void assertCacheContains(Consumer> checker) { + withRealm(realmId, (session, realm) -> { + HotRodConnectionProvider provider = session.getProvider(HotRodConnectionProvider.class); + RemoteCache remoteCache = provider.getRemoteCache(ModelEntityUtil.getModelName(UserSessionModel.class)); + checker.accept(remoteCache); + return null; + }); + } + + private void prepareSessions(AtomicReference uSessionId, AtomicReference cSessionId) { + withRealm(realmId, (session, realm) -> { + UserSessionModel uSession = session.sessions().createUserSession(realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.1", "form", true, null, null); + ClientModel client = realm.getClientByClientId(CLIENT0_CLIENT_ID); + + AuthenticatedClientSessionModel cSession = session.sessions().createClientSession(realm, client, uSession); + + uSessionId.set(uSession.getId()); + cSessionId.set(cSession.getId()); + return null; + }); + + assertCacheContains(remoteCache -> { + assertThat(remoteCache, aMapWithSize(2)); + assertThat(remoteCache.keySet(), containsInAnyOrder(uSessionId.get(), cSessionId.get())); + }); + } +} diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionConcurrencyTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionConcurrencyTest.java new file mode 100644 index 0000000000..dc379a17cb --- /dev/null +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionConcurrencyTest.java @@ -0,0 +1,190 @@ +/* + * 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.testsuite.model.session; + +import org.junit.Test; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakTransaction; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.models.map.storage.ModelEntityUtil; +import org.keycloak.models.map.storage.hotRod.HotRodMapStorageProviderFactory; +import org.keycloak.models.map.storage.hotRod.connections.HotRodConnectionProvider; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.testsuite.model.KeycloakModelTest; +import org.keycloak.testsuite.model.RequireProvider; + +import java.util.Map; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import java.util.stream.IntStream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.startsWith; +import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification; + + +@RequireProvider(UserSessionProvider.class) +public class UserSessionConcurrencyTest extends KeycloakModelTest { + + private String realmId; + private static final int CLIENTS_COUNT = 10; + + private static final Lock SYNC_USESSION = new ReentrantLock(); + private static final ThreadLocal wasWriting = ThreadLocal.withInitial(() -> false); + private final boolean isHotRodStore = HotRodMapStorageProviderFactory.PROVIDER_ID.equals(CONFIG.getConfig().get("userSessions.map.storage.provider")); + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().createRealm("test"); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + realm.setSsoSessionIdleTimeout(1800); + realm.setSsoSessionMaxLifespan(36000); + realm.setClientSessionIdleTimeout(500); + this.realmId = realm.getId(); + + s.users().addUser(realm, "user1").setEmail("user1@localhost"); + s.users().addUser(realm, "user2").setEmail("user2@localhost"); + + for (int i = 0; i < CLIENTS_COUNT; i++) { + s.clients().addClient(realm, "client" + i); + } + } + + @Override + protected boolean isUseSameKeycloakSessionFactoryForAllThreads() { + return true; + } + + @Test + public void testConcurrentNotesChange() { + // Create user session + String uId = withRealm(this.realmId, (session, realm) -> session.sessions().createUserSession(realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.1", "form", true, null, null)).getId(); + + // Create/Update client session's notes concurrently + IntStream.range(0, 200 * CLIENTS_COUNT).parallel() + .forEach(i -> inComittedTransaction(i, (session, n) -> { + RealmModel realm = session.realms().getRealm(realmId); + ClientModel client = realm.getClientByClientId("client" + (n % CLIENTS_COUNT)); + + // THIS SHOULD BE REMOVED AS PART OF ISSUE https://github.com/keycloak/keycloak/issues/13273 + // Without this lock more threads can create client session but only one of them is referenced from + // user session. All others that are not referenced are basically lost and should not be created. + // In other words, this lock is to make sure only one thread creates client session, all other + // should use client session created by the first thread + // + // This is basically the same as JpaMapKeycloakTransaction#read method is doing after calling lockUserSessionsForModification() method + if (isHotRodStore) { + SYNC_USESSION.lock(); + releaseLockOnTransactionCommit(session, SYNC_USESSION); + } + + UserSessionModel uSession = lockUserSessionsForModification(session, () -> session.sessions().getUserSession(realm, uId)); + AuthenticatedClientSessionModel cSession = uSession.getAuthenticatedClientSessionByClient(client.getId()); + if (cSession == null) { + wasWriting.set(true); + cSession = session.sessions().createClientSession(realm, client, uSession); + } + + cSession.setNote(OIDCLoginProtocol.STATE_PARAM, "state-" + n); + + return null; + })); + + withRealm(this.realmId, (session, realm) -> { + UserSessionModel uSession = session.sessions().getUserSession(realm, uId); + assertThat(uSession.getAuthenticatedClientSessions(), aMapWithSize(CLIENTS_COUNT)); + + for (int i = 0; i < CLIENTS_COUNT; i++) { + ClientModel client = realm.getClientByClientId("client" + (i % CLIENTS_COUNT)); + AuthenticatedClientSessionModel cSession = uSession.getAuthenticatedClientSessionByClient(client.getId()); + + assertThat(cSession.getNote(OIDCLoginProtocol.STATE_PARAM), startsWith("state-")); + } + + return null; + }); + + inComittedTransaction((Consumer) session -> session.realms().removeRealm(realmId)); + if (isHotRodStore) { + inComittedTransaction(session -> { + HotRodConnectionProvider provider = session.getProvider(HotRodConnectionProvider.class); + Map remoteCache = provider.getRemoteCache(ModelEntityUtil.getModelName(UserSessionModel.class)); + + assertThat(remoteCache, anEmptyMap()); + }); + } + } + + private void releaseLockOnTransactionCommit(KeycloakSession session, Lock l) { + session.getTransactionManager().enlistAfterCompletion(new KeycloakTransaction() { + @Override + public void begin() { + + } + + @Override + public void commit() { + // THIS IS WORKAROUND FOR MISSING https://github.com/keycloak/keycloak/issues/13280 + // It happens that calling remoteCache.put() in one thread and remoteCache.get() in another thread after + // releasing the l lock is so fast that changes are not yet present in the Infinispan server, to avoid + // this we need to leverage HotRod transactions that makes sure the changes are propagated to Infinispan + // server in commit phase + // + // In other words, we need to give Infinispan some time to process put request before we let other + // threads query client session created in this transaction + if (isHotRodStore && wasWriting.get()) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + wasWriting.set(false); + } + l.unlock(); + } + + @Override + public void rollback() { + l.unlock(); + } + + @Override + public void setRollbackOnly() { + + } + + @Override + public boolean getRollbackOnly() { + return false; + } + + @Override + public boolean isActive() { + return false; + } + }); + } +}