From 2b6489c2bd9a6840bfa33ec696b30840fb8fd036 Mon Sep 17 00:00:00 2001 From: Eleftheria Stein Date: Mon, 14 Sep 2020 17:50:03 +0200 Subject: [PATCH] Add support for Hazelcast 4 Closes gh-1584 --- settings.gradle | 2 + .../hazelcast4/hazelcast4.gradle | 37 ++ ...elcast4IndexedSessionRepositoryITests.java | 200 +++++++ ...elcast4IndexedSessionRepositoryITests.java | 78 +++ ...elcast4IndexedSessionRepositoryITests.java | 51 ++ .../hazelcast/Hazelcast4ITestUtils.java | 61 +++ ...zelcast4IndexedSessionRepositoryTests.java | 220 ++++++++ .../hazelcast/SessionEventRegistry.java | 72 +++ .../resources/hazelcast-server.xml | 17 + .../resources/testcontainers.properties | 1 + .../Hazelcast4IndexedSessionRepository.java | 486 ++++++++++++++++++ .../Hazelcast4PrincipalNameExtractor.java | 43 ++ ...Hazelcast4SessionUpdateEntryProcessor.java | 86 ++++ ...zelcast4IndexedSessionRepositoryTests.java | 472 +++++++++++++++++ .../spring-session-hazelcast.gradle | 12 + .../web/http/EnableHazelcastHttpSession.java | 8 +- .../EnableHazelcastHttpSessionSelector.java | 48 ++ .../Hazelcast4HttpSessionConfiguration.java | 161 ++++++ 18 files changed, 2050 insertions(+), 5 deletions(-) create mode 100644 spring-session-hazelcast/hazelcast4/hazelcast4.gradle create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/AbstractHazelcast4IndexedSessionRepositoryITests.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/ClientServerHazelcast4IndexedSessionRepositoryITests.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/EmbeddedHazelcast4IndexedSessionRepositoryITests.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/Hazelcast4ITestUtils.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventHazelcast4IndexedSessionRepositoryTests.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventRegistry.java create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/resources/hazelcast-server.xml create mode 100644 spring-session-hazelcast/hazelcast4/src/integration-test/resources/testcontainers.properties create mode 100644 spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4IndexedSessionRepository.java create mode 100644 spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4PrincipalNameExtractor.java create mode 100644 spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4SessionUpdateEntryProcessor.java create mode 100644 spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/Hazelcast4IndexedSessionRepositoryTests.java create mode 100644 spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSessionSelector.java create mode 100644 spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/Hazelcast4HttpSessionConfiguration.java diff --git a/settings.gradle b/settings.gradle index 91ba66f8..e2e33166 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,6 +5,8 @@ include 'spring-session-data-redis' include 'spring-session-docs' include 'spring-session-hazelcast' include 'spring-session-jdbc' +include 'hazelcast4' +project(':hazelcast4').projectDir = file('spring-session-hazelcast/hazelcast4') file('spring-session-samples').eachDirMatch(~/spring-session-sample-.*/) { dir -> include dir.name diff --git a/spring-session-hazelcast/hazelcast4/hazelcast4.gradle b/spring-session-hazelcast/hazelcast4/hazelcast4.gradle new file mode 100644 index 00000000..be5eb81a --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/hazelcast4.gradle @@ -0,0 +1,37 @@ +plugins { + id 'java-library' + id 'io.spring.convention.repository' + id 'io.spring.convention.springdependencymangement' + id 'io.spring.convention.dependency-set' + id 'io.spring.convention.checkstyle' + id 'io.spring.convention.tests-configuration' + id 'io.spring.convention.integration-test' +} + +configurations { + classesOnlyElements { + canBeConsumed = true + canBeResolved = false + } +} + +artifacts { + classesOnlyElements(compileJava.destinationDir) +} + +dependencies { + compile project(':spring-session-core') + compile "com.hazelcast:hazelcast:4.0.2" + compile "org.springframework:spring-context" + + testCompile "javax.servlet:javax.servlet-api" + testCompile "org.springframework:spring-web" + testCompile "org.junit.jupiter:junit-jupiter-api" + testCompile "org.springframework.security:spring-security-core" + testRuntime "org.junit.jupiter:junit-jupiter-engine" + + integrationTestCompile "org.testcontainers:testcontainers" + integrationTestCompile "com.hazelcast:hazelcast:4.0.2" + integrationTestCompile project(":spring-session-hazelcast") +} + diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/AbstractHazelcast4IndexedSessionRepositoryITests.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/AbstractHazelcast4IndexedSessionRepositoryITests.java new file mode 100644 index 00000000..b633fe4e --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/AbstractHazelcast4IndexedSessionRepositoryITests.java @@ -0,0 +1,200 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.session.MapSession; +import org.springframework.session.hazelcast.Hazelcast4IndexedSessionRepository.HazelcastSession; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Base class for {@link Hazelcast4IndexedSessionRepository} integration tests. + * + * @author Eleftheria Stein + */ +abstract class AbstractHazelcast4IndexedSessionRepositoryITests { + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + @Autowired + private HazelcastInstance hazelcastInstance; + + @Autowired + private Hazelcast4IndexedSessionRepository repository; + + @Test + void createAndDestroySession() { + HazelcastSession sessionToSave = this.repository.createSession(); + String sessionId = sessionToSave.getId(); + + IMap hazelcastMap = this.hazelcastInstance + .getMap(Hazelcast4IndexedSessionRepository.DEFAULT_SESSION_MAP_NAME); + + assertThat(hazelcastMap.size()).isEqualTo(0); + + this.repository.save(sessionToSave); + + assertThat(hazelcastMap.size()).isEqualTo(1); + assertThat(hazelcastMap.get(sessionId)).isEqualTo(sessionToSave); + + this.repository.deleteById(sessionId); + + assertThat(hazelcastMap.size()).isEqualTo(0); + } + + @Test + void changeSessionIdWhenOnlyChangeId() { + String attrName = "changeSessionId"; + String attrValue = "changeSessionId-value"; + HazelcastSession toSave = this.repository.createSession(); + toSave.setAttribute(attrName, attrValue); + + this.repository.save(toSave); + + HazelcastSession findById = this.repository.findById(toSave.getId()); + + assertThat(findById.getAttribute(attrName)).isEqualTo(attrValue); + + String originalFindById = findById.getId(); + String changeSessionId = findById.changeSessionId(); + + this.repository.save(findById); + + assertThat(this.repository.findById(originalFindById)).isNull(); + + HazelcastSession findByChangeSessionId = this.repository.findById(changeSessionId); + + assertThat(findByChangeSessionId.getAttribute(attrName)).isEqualTo(attrValue); + + this.repository.deleteById(changeSessionId); + } + + @Test + void changeSessionIdWhenChangeTwice() { + HazelcastSession toSave = this.repository.createSession(); + + this.repository.save(toSave); + + String originalId = toSave.getId(); + String changeId1 = toSave.changeSessionId(); + String changeId2 = toSave.changeSessionId(); + + this.repository.save(toSave); + + assertThat(this.repository.findById(originalId)).isNull(); + assertThat(this.repository.findById(changeId1)).isNull(); + assertThat(this.repository.findById(changeId2)).isNotNull(); + + this.repository.deleteById(changeId2); + } + + @Test + void changeSessionIdWhenSetAttributeOnChangedSession() { + String attrName = "changeSessionId"; + String attrValue = "changeSessionId-value"; + + HazelcastSession toSave = this.repository.createSession(); + + this.repository.save(toSave); + + HazelcastSession findById = this.repository.findById(toSave.getId()); + + findById.setAttribute(attrName, attrValue); + + String originalFindById = findById.getId(); + String changeSessionId = findById.changeSessionId(); + + this.repository.save(findById); + + assertThat(this.repository.findById(originalFindById)).isNull(); + + HazelcastSession findByChangeSessionId = this.repository.findById(changeSessionId); + + assertThat(findByChangeSessionId.getAttribute(attrName)).isEqualTo(attrValue); + + this.repository.deleteById(changeSessionId); + } + + @Test + void changeSessionIdWhenHasNotSaved() { + HazelcastSession toSave = this.repository.createSession(); + String originalId = toSave.getId(); + toSave.changeSessionId(); + + this.repository.save(toSave); + + assertThat(this.repository.findById(toSave.getId())).isNotNull(); + assertThat(this.repository.findById(originalId)).isNull(); + + this.repository.deleteById(toSave.getId()); + } + + @Test // gh-1076 + void attemptToUpdateSessionAfterDelete() { + HazelcastSession session = this.repository.createSession(); + String sessionId = session.getId(); + this.repository.save(session); + session = this.repository.findById(sessionId); + session.setAttribute("attributeName", "attributeValue"); + this.repository.deleteById(sessionId); + this.repository.save(session); + + assertThat(this.repository.findById(sessionId)).isNull(); + } + + @Test + void createAndUpdateSession() { + HazelcastSession session = this.repository.createSession(); + String sessionId = session.getId(); + + this.repository.save(session); + + session = this.repository.findById(sessionId); + session.setAttribute("attributeName", "attributeValue"); + + this.repository.save(session); + + assertThat(this.repository.findById(sessionId)).isNotNull(); + } + + @Test + void createSessionWithSecurityContextAndFindById() { + HazelcastSession session = this.repository.createSession(); + String sessionId = session.getId(); + + Authentication authentication = new UsernamePasswordAuthenticationToken("saves-" + System.currentTimeMillis(), + "password", AuthorityUtils.createAuthorityList("ROLE_USER")); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + session.setAttribute(SPRING_SECURITY_CONTEXT, securityContext); + + this.repository.save(session); + + assertThat(this.repository.findById(sessionId)).isNotNull(); + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/ClientServerHazelcast4IndexedSessionRepositoryITests.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/ClientServerHazelcast4IndexedSessionRepositoryITests.java new file mode 100644 index 00000000..7c031cfb --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/ClientServerHazelcast4IndexedSessionRepositoryITests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import com.hazelcast.client.HazelcastClient; +import com.hazelcast.client.config.ClientConfig; +import com.hazelcast.core.HazelcastInstance; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.MountableFile; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.MapSession; +import org.springframework.session.Session; +import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * Integration tests for {@link Hazelcast4IndexedSessionRepository} using client-server + * topology. + * + * @author Eleftheria Stein + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +class ClientServerHazelcast4IndexedSessionRepositoryITests extends AbstractHazelcast4IndexedSessionRepositoryITests { + + private static GenericContainer container = new GenericContainer<>("hazelcast/hazelcast:4.0.2") + .withExposedPorts(5701).withCopyFileToContainer(MountableFile.forClasspathResource("/hazelcast-server.xml"), + "/opt/hazelcast/hazelcast.xml"); + + @BeforeAll + static void setUpClass() { + container.start(); + } + + @AfterAll + static void tearDownClass() { + container.stop(); + } + + @Configuration + @EnableHazelcastHttpSession + static class HazelcastSessionConfig { + + @Bean + HazelcastInstance hazelcastInstance() { + ClientConfig clientConfig = new ClientConfig(); + clientConfig.getNetworkConfig() + .addAddress(container.getContainerIpAddress() + ":" + container.getFirstMappedPort()); + clientConfig.getUserCodeDeploymentConfig().setEnabled(true).addClass(Session.class) + .addClass(MapSession.class).addClass(Hazelcast4SessionUpdateEntryProcessor.class); + return HazelcastClient.newHazelcastClient(clientConfig); + } + + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/EmbeddedHazelcast4IndexedSessionRepositoryITests.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/EmbeddedHazelcast4IndexedSessionRepositoryITests.java new file mode 100644 index 00000000..66db1b85 --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/EmbeddedHazelcast4IndexedSessionRepositoryITests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import com.hazelcast.core.HazelcastInstance; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * Integration tests for {@link Hazelcast4IndexedSessionRepository} using embedded + * topology. + * + * @author Eleftheria Stein + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +class EmbeddedHazelcast4IndexedSessionRepositoryITests extends AbstractHazelcast4IndexedSessionRepositoryITests { + + @EnableHazelcastHttpSession + @Configuration + static class HazelcastSessionConfig { + + @Bean + HazelcastInstance hazelcastInstance() { + return Hazelcast4ITestUtils.embeddedHazelcastServer(); + } + + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/Hazelcast4ITestUtils.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/Hazelcast4ITestUtils.java new file mode 100644 index 00000000..5c5c1d12 --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/Hazelcast4ITestUtils.java @@ -0,0 +1,61 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import com.hazelcast.config.AttributeConfig; +import com.hazelcast.config.Config; +import com.hazelcast.config.IndexConfig; +import com.hazelcast.config.IndexType; +import com.hazelcast.config.NetworkConfig; +import com.hazelcast.config.SerializerConfig; +import com.hazelcast.core.Hazelcast; +import com.hazelcast.core.HazelcastInstance; + +import org.springframework.session.MapSession; + +/** + * Utility class for Hazelcast integration tests. + * + * @author Eleftheria Stein + */ +final class Hazelcast4ITestUtils { + + private Hazelcast4ITestUtils() { + } + + /** + * Creates {@link HazelcastInstance} for use in integration tests. + * @return the Hazelcast instance + */ + static HazelcastInstance embeddedHazelcastServer() { + Config config = new Config(); + NetworkConfig networkConfig = config.getNetworkConfig(); + networkConfig.setPort(0); + networkConfig.getJoin().getMulticastConfig().setEnabled(false); + AttributeConfig attributeConfig = new AttributeConfig() + .setName(Hazelcast4IndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE) + .setExtractorClassName(Hazelcast4PrincipalNameExtractor.class.getName()); + config.getMapConfig(Hazelcast4IndexedSessionRepository.DEFAULT_SESSION_MAP_NAME) + .addAttributeConfig(attributeConfig).addIndexConfig( + new IndexConfig(IndexType.HASH, Hazelcast4IndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE)); + SerializerConfig serializerConfig = new SerializerConfig(); + serializerConfig.setImplementation(new HazelcastSessionSerializer()).setTypeClass(MapSession.class); + config.getSerializationConfig().addSerializerConfig(serializerConfig); + return Hazelcast.newHazelcastInstance(config); + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventHazelcast4IndexedSessionRepositoryTests.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventHazelcast4IndexedSessionRepositoryTests.java new file mode 100644 index 00000000..ab293e99 --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventHazelcast4IndexedSessionRepositoryTests.java @@ -0,0 +1,220 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import java.time.Duration; +import java.time.Instant; + +import com.hazelcast.core.HazelcastInstance; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.SessionRepository; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionExpiredEvent; +import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Ensure that the appropriate SessionEvents are fired at the expected times. Additionally + * ensure that the interactions with the {@link SessionRepository} abstraction behave as + * expected after each SessionEvent. + * + * @author Eleftheria Stein + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +class SessionEventHazelcast4IndexedSessionRepositoryTests { + + private static final int MAX_INACTIVE_INTERVAL_IN_SECONDS = 1; + + @Autowired + private SessionRepository repository; + + @Autowired + private SessionEventRegistry registry; + + @BeforeEach + void setup() { + this.registry.clear(); + } + + @Test + void saveSessionTest() throws InterruptedException { + String username = "saves-" + System.currentTimeMillis(); + + S sessionToSave = this.repository.createSession(); + + String expectedAttributeName = "a"; + String expectedAttributeValue = "b"; + sessionToSave.setAttribute(expectedAttributeName, expectedAttributeValue); + Authentication toSaveToken = new UsernamePasswordAuthenticationToken(username, "password", + AuthorityUtils.createAuthorityList("ROLE_USER")); + SecurityContext toSaveContext = SecurityContextHolder.createEmptyContext(); + toSaveContext.setAuthentication(toSaveToken); + sessionToSave.setAttribute("SPRING_SECURITY_CONTEXT", toSaveContext); + sessionToSave.setAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, username); + + this.repository.save(sessionToSave); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + + Session session = this.repository.findById(sessionToSave.getId()); + + assertThat(session.getId()).isEqualTo(sessionToSave.getId()); + assertThat(session.getAttributeNames()).isEqualTo(sessionToSave.getAttributeNames()); + assertThat(session.getAttribute(expectedAttributeName)) + .isEqualTo(sessionToSave.getAttribute(expectedAttributeName)); + } + + @Test + void expiredSessionTest() throws InterruptedException { + S sessionToSave = this.repository.createSession(); + + this.repository.save(sessionToSave); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + assertThat(sessionToSave.getMaxInactiveInterval()) + .isEqualTo(Duration.ofSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS)); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionExpiredEvent.class); + + assertThat(this.repository.findById(sessionToSave.getId())).isNull(); + } + + @Test + void deletedSessionTest() throws InterruptedException { + S sessionToSave = this.repository.createSession(); + + this.repository.save(sessionToSave); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + this.repository.deleteById(sessionToSave.getId()); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionDeletedEvent.class); + + assertThat(this.repository.findById(sessionToSave.getId())).isNull(); + } + + @Test + void saveUpdatesTimeToLiveTest() throws InterruptedException { + S sessionToSave = this.repository.createSession(); + sessionToSave.setMaxInactiveInterval(Duration.ofSeconds(3)); + this.repository.save(sessionToSave); + + Thread.sleep(2000); + + // Get and save the session like SessionRepositoryFilter would. + S sessionToUpdate = this.repository.findById(sessionToSave.getId()); + sessionToUpdate.setLastAccessedTime(Instant.now()); + this.repository.save(sessionToUpdate); + + Thread.sleep(2000); + + assertThat(this.repository.findById(sessionToUpdate.getId())).isNotNull(); + } + + @Test // gh-1077 + void changeSessionIdNoEventTest() throws InterruptedException { + S sessionToSave = this.repository.createSession(); + sessionToSave.setMaxInactiveInterval(Duration.ofMinutes(30)); + + this.repository.save(sessionToSave); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + sessionToSave.changeSessionId(); + this.repository.save(sessionToSave); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isFalse(); + } + + @Test // gh-1300 + @Disabled("See https://github.com/hazelcast/hazelcast/issues/16987") + void updateMaxInactiveIntervalTest() throws InterruptedException { + S sessionToSave = this.repository.createSession(); + sessionToSave.setMaxInactiveInterval(Duration.ofMinutes(30)); + this.repository.save(sessionToSave); + + assertThat(this.registry.receivedEvent(sessionToSave.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToSave.getId())) + .isInstanceOf(SessionCreatedEvent.class); + this.registry.clear(); + + S sessionToUpdate = this.repository.findById(sessionToSave.getId()); + sessionToUpdate.setLastAccessedTime(Instant.now()); + sessionToUpdate.setMaxInactiveInterval(Duration.ofSeconds(1)); + this.repository.save(sessionToUpdate); + + assertThat(this.registry.receivedEvent(sessionToUpdate.getId())).isTrue(); + assertThat(this.registry.getEvent(sessionToUpdate.getId())) + .isInstanceOf(SessionExpiredEvent.class); + assertThat(this.repository.findById(sessionToUpdate.getId())).isNull(); + } + + @Configuration + @EnableHazelcastHttpSession(maxInactiveIntervalInSeconds = MAX_INACTIVE_INTERVAL_IN_SECONDS) + static class HazelcastSessionConfig { + + @Bean + HazelcastInstance embeddedHazelcast() { + return Hazelcast4ITestUtils.embeddedHazelcastServer(); + } + + @Bean + SessionEventRegistry sessionEventRegistry() { + return new SessionEventRegistry(); + } + + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventRegistry.java b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventRegistry.java new file mode 100644 index 00000000..0cee61e3 --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/java/org/springframework/session/hazelcast/SessionEventRegistry.java @@ -0,0 +1,72 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.context.ApplicationListener; +import org.springframework.session.events.AbstractSessionEvent; + +class SessionEventRegistry implements ApplicationListener { + + private Map events = new HashMap<>(); + + private ConcurrentMap locks = new ConcurrentHashMap<>(); + + @Override + public void onApplicationEvent(AbstractSessionEvent event) { + String sessionId = event.getSessionId(); + this.events.put(sessionId, event); + Object lock = getLock(sessionId); + synchronized (lock) { + lock.notifyAll(); + } + } + + void clear() { + this.events.clear(); + this.locks.clear(); + } + + boolean receivedEvent(String sessionId) throws InterruptedException { + return waitForEvent(sessionId) != null; + } + + @SuppressWarnings("unchecked") + E getEvent(String sessionId) throws InterruptedException { + return (E) waitForEvent(sessionId); + } + + @SuppressWarnings("unchecked") + private E waitForEvent(String sessionId) throws InterruptedException { + Object lock = getLock(sessionId); + synchronized (lock) { + if (!this.events.containsKey(sessionId)) { + lock.wait(10000); + } + } + return (E) this.events.get(sessionId); + } + + private Object getLock(String sessionId) { + return this.locks.computeIfAbsent(sessionId, (k) -> new Object()); + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/resources/hazelcast-server.xml b/spring-session-hazelcast/hazelcast4/src/integration-test/resources/hazelcast-server.xml new file mode 100644 index 00000000..cf29846c --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/resources/hazelcast-server.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + ETERNAL + LOCAL_AND_CACHED_CLASSES + + + diff --git a/spring-session-hazelcast/hazelcast4/src/integration-test/resources/testcontainers.properties b/spring-session-hazelcast/hazelcast4/src/integration-test/resources/testcontainers.properties new file mode 100644 index 00000000..e3e83419 --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/integration-test/resources/testcontainers.properties @@ -0,0 +1 @@ +ryuk.container.timeout=120 diff --git a/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4IndexedSessionRepository.java b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4IndexedSessionRepository.java new file mode 100644 index 00000000..ddb4d13f --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4IndexedSessionRepository.java @@ -0,0 +1,486 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +import com.hazelcast.core.EntryEvent; +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import com.hazelcast.map.listener.EntryAddedListener; +import com.hazelcast.map.listener.EntryEvictedListener; +import com.hazelcast.map.listener.EntryExpiredListener; +import com.hazelcast.map.listener.EntryRemovedListener; +import com.hazelcast.query.Predicates; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.session.DelegatingIndexResolver; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.FlushMode; +import org.springframework.session.IndexResolver; +import org.springframework.session.MapSession; +import org.springframework.session.PrincipalNameIndexResolver; +import org.springframework.session.SaveMode; +import org.springframework.session.Session; +import org.springframework.session.events.AbstractSessionEvent; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionExpiredEvent; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * A {@link org.springframework.session.SessionRepository} implementation using Hazelcast + * 4 that stores sessions in Hazelcast's distributed {@link IMap}. + * + *

+ * An example of how to create a new instance can be seen below: + * + *

+ * Config config = new Config();
+ *
+ * // ... configure Hazelcast ...
+ *
+ * HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance(config);
+ *
+ * Hazelcast4IndexedSessionRepository sessionRepository =
+ *         new Hazelcast4IndexedSessionRepository(hazelcastInstance);
+ * 
+ * + * In order to support finding sessions by principal name using + * {@link #findByIndexNameAndIndexValue(String, String)} method, custom configuration of + * {@code IMap} supplied to this implementation is required. + * + * The following snippet demonstrates how to define required configuration using + * programmatic Hazelcast Configuration: + * + *
+ * AttributeConfig attributeConfig = new AttributeConfig()
+ *         .setName(Hazelcast4IndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE)
+ *         .setExtractorClassName(Hazelcast4PrincipalNameExtractor.class.getName());
+ *
+ * Config config = new Config();
+ *
+ * config.getMapConfig(Hazelcast4IndexedSessionRepository.DEFAULT_SESSION_MAP_NAME)
+ *         .addAttributeConfig(attributeConfig)
+ *         .addIndexConfig(new IndexConfig(
+ *                 IndexType.HASH,
+ *                 Hazelcast4IndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE));
+ *
+ * Hazelcast.newHazelcastInstance(config);
+ * 
+ * + * This implementation listens for events on the Hazelcast-backed SessionRepository and + * translates those events into the corresponding Spring Session events. Publish the + * Spring Session events with the given {@link ApplicationEventPublisher}. + * + *
    + *
  • entryAdded - {@link SessionCreatedEvent}
  • + *
  • entryEvicted - {@link SessionExpiredEvent}
  • + *
  • entryExpired - {@link SessionExpiredEvent}
  • + *
  • entryRemoved - {@link SessionDeletedEvent}
  • + *
+ * + * @author Eleftheria Stein + * @since 2.4.0 + */ +public class Hazelcast4IndexedSessionRepository + implements FindByIndexNameSessionRepository, + EntryAddedListener, EntryEvictedListener, + EntryRemovedListener, EntryExpiredListener { + + /** + * The default name of map used by Spring Session to store sessions. + */ + public static final String DEFAULT_SESSION_MAP_NAME = "spring:session:sessions"; + + /** + * The principal name custom attribute name. + */ + public static final String PRINCIPAL_NAME_ATTRIBUTE = "principalName"; + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + private static final boolean SUPPORTS_SET_TTL = ClassUtils.hasAtLeastOneMethodWithName(IMap.class, "setTtl"); + + private static final Log logger = LogFactory.getLog(Hazelcast4IndexedSessionRepository.class); + + private final HazelcastInstance hazelcastInstance; + + private ApplicationEventPublisher eventPublisher = (event) -> { + }; + + /** + * If non-null, this value is used to override + * {@link MapSession#setMaxInactiveInterval(Duration)}. + */ + private Integer defaultMaxInactiveInterval; + + private IndexResolver indexResolver = new DelegatingIndexResolver<>(new PrincipalNameIndexResolver<>()); + + private String sessionMapName = DEFAULT_SESSION_MAP_NAME; + + private FlushMode flushMode = FlushMode.ON_SAVE; + + private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + + private IMap sessions; + + private UUID sessionListenerId; + + /** + * Create a new {@link Hazelcast4IndexedSessionRepository} instance. + * @param hazelcastInstance the {@link HazelcastInstance} to use for managing sessions + */ + public Hazelcast4IndexedSessionRepository(HazelcastInstance hazelcastInstance) { + Assert.notNull(hazelcastInstance, "HazelcastInstance must not be null"); + this.hazelcastInstance = hazelcastInstance; + } + + @PostConstruct + public void init() { + this.sessions = this.hazelcastInstance.getMap(this.sessionMapName); + this.sessionListenerId = this.sessions.addEntryListener(this, true); + } + + @PreDestroy + public void close() { + this.sessions.removeEntryListener(this.sessionListenerId); + } + + /** + * Sets the {@link ApplicationEventPublisher} that is used to publish + * {@link AbstractSessionEvent session events}. The default is to not publish session + * events. + * @param applicationEventPublisher the {@link ApplicationEventPublisher} that is used + * to publish session events. Cannot be null. + */ + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + Assert.notNull(applicationEventPublisher, "ApplicationEventPublisher cannot be null"); + this.eventPublisher = applicationEventPublisher; + } + + /** + * Set the maximum inactive interval in seconds between requests before newly created + * sessions will be invalidated. A negative time indicates that the session will never + * timeout. The default is 1800 (30 minutes). + * @param defaultMaxInactiveInterval the maximum inactive interval in seconds + */ + public void setDefaultMaxInactiveInterval(Integer defaultMaxInactiveInterval) { + this.defaultMaxInactiveInterval = defaultMaxInactiveInterval; + } + + /** + * Set the {@link IndexResolver} to use. + * @param indexResolver the index resolver + */ + public void setIndexResolver(IndexResolver indexResolver) { + Assert.notNull(indexResolver, "indexResolver cannot be null"); + this.indexResolver = indexResolver; + } + + /** + * Set the name of map used to store sessions. + * @param sessionMapName the session map name + */ + public void setSessionMapName(String sessionMapName) { + Assert.hasText(sessionMapName, "Map name must not be empty"); + this.sessionMapName = sessionMapName; + } + + /** + * Sets the Hazelcast flush mode. Default flush mode is {@link FlushMode#ON_SAVE}. + * @param flushMode the new Hazelcast flush mode + */ + public void setFlushMode(FlushMode flushMode) { + Assert.notNull(flushMode, "flushMode cannot be null"); + this.flushMode = flushMode; + } + + /** + * Set the save mode. + * @param saveMode the save mode + */ + public void setSaveMode(SaveMode saveMode) { + Assert.notNull(saveMode, "saveMode must not be null"); + this.saveMode = saveMode; + } + + @Override + public HazelcastSession createSession() { + MapSession cached = new MapSession(); + if (this.defaultMaxInactiveInterval != null) { + cached.setMaxInactiveInterval(Duration.ofSeconds(this.defaultMaxInactiveInterval)); + } + HazelcastSession session = new HazelcastSession(cached, true); + session.flushImmediateIfNecessary(); + return session; + } + + @Override + public void save(HazelcastSession session) { + if (session.isNew) { + this.sessions.set(session.getId(), session.getDelegate(), session.getMaxInactiveInterval().getSeconds(), + TimeUnit.SECONDS); + } + else if (session.sessionIdChanged) { + this.sessions.delete(session.originalId); + session.originalId = session.getId(); + this.sessions.set(session.getId(), session.getDelegate(), session.getMaxInactiveInterval().getSeconds(), + TimeUnit.SECONDS); + } + else if (session.hasChanges()) { + Hazelcast4SessionUpdateEntryProcessor entryProcessor = new Hazelcast4SessionUpdateEntryProcessor(); + if (session.lastAccessedTimeChanged) { + entryProcessor.setLastAccessedTime(session.getLastAccessedTime()); + } + if (session.maxInactiveIntervalChanged) { + if (SUPPORTS_SET_TTL) { + updateTtl(session); + } + entryProcessor.setMaxInactiveInterval(session.getMaxInactiveInterval()); + } + if (!session.delta.isEmpty()) { + entryProcessor.setDelta(new HashMap<>(session.delta)); + } + this.sessions.executeOnKey(session.getId(), entryProcessor); + } + session.clearChangeFlags(); + } + + private void updateTtl(HazelcastSession session) { + this.sessions.setTtl(session.getId(), session.getMaxInactiveInterval().getSeconds(), TimeUnit.SECONDS); + } + + @Override + public HazelcastSession findById(String id) { + MapSession saved = this.sessions.get(id); + if (saved == null) { + return null; + } + if (saved.isExpired()) { + deleteById(saved.getId()); + return null; + } + return new HazelcastSession(saved, false); + } + + @Override + public void deleteById(String id) { + this.sessions.remove(id); + } + + @Override + public Map findByIndexNameAndIndexValue(String indexName, String indexValue) { + if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) { + return Collections.emptyMap(); + } + Collection sessions = this.sessions.values(Predicates.equal(PRINCIPAL_NAME_ATTRIBUTE, indexValue)); + Map sessionMap = new HashMap<>(sessions.size()); + for (MapSession session : sessions) { + sessionMap.put(session.getId(), new HazelcastSession(session, false)); + } + return sessionMap; + } + + @Override + public void entryAdded(EntryEvent event) { + MapSession session = event.getValue(); + if (session.getId().equals(session.getOriginalId())) { + if (logger.isDebugEnabled()) { + logger.debug("Session created with id: " + session.getId()); + } + this.eventPublisher.publishEvent(new SessionCreatedEvent(this, session)); + } + } + + @Override + public void entryEvicted(EntryEvent event) { + if (logger.isDebugEnabled()) { + logger.debug("Session expired with id: " + event.getOldValue().getId()); + } + this.eventPublisher.publishEvent(new SessionExpiredEvent(this, event.getOldValue())); + } + + @Override + public void entryRemoved(EntryEvent event) { + MapSession session = event.getOldValue(); + if (session != null) { + if (logger.isDebugEnabled()) { + logger.debug("Session deleted with id: " + session.getId()); + } + this.eventPublisher.publishEvent(new SessionDeletedEvent(this, session)); + } + } + + @Override + public void entryExpired(EntryEvent event) { + if (logger.isDebugEnabled()) { + logger.debug("Session expired with id: " + event.getOldValue().getId()); + } + this.eventPublisher.publishEvent(new SessionExpiredEvent(this, event.getOldValue())); + } + + /** + * A custom implementation of {@link Session} that uses a {@link MapSession} as the + * basis for its mapping. It keeps track if changes have been made since last save. + * + * @author Aleksandar Stojsavljevic + */ + final class HazelcastSession implements Session { + + private final MapSession delegate; + + private boolean isNew; + + private boolean sessionIdChanged; + + private boolean lastAccessedTimeChanged; + + private boolean maxInactiveIntervalChanged; + + private String originalId; + + private Map delta = new HashMap<>(); + + HazelcastSession(MapSession cached, boolean isNew) { + this.delegate = cached; + this.isNew = isNew; + this.originalId = cached.getId(); + if (this.isNew || (Hazelcast4IndexedSessionRepository.this.saveMode == SaveMode.ALWAYS)) { + getAttributeNames() + .forEach((attributeName) -> this.delta.put(attributeName, cached.getAttribute(attributeName))); + } + } + + @Override + public void setLastAccessedTime(Instant lastAccessedTime) { + this.delegate.setLastAccessedTime(lastAccessedTime); + this.lastAccessedTimeChanged = true; + flushImmediateIfNecessary(); + } + + @Override + public boolean isExpired() { + return this.delegate.isExpired(); + } + + @Override + public Instant getCreationTime() { + return this.delegate.getCreationTime(); + } + + @Override + public String getId() { + return this.delegate.getId(); + } + + @Override + public String changeSessionId() { + String newSessionId = this.delegate.changeSessionId(); + this.sessionIdChanged = true; + return newSessionId; + } + + @Override + public Instant getLastAccessedTime() { + return this.delegate.getLastAccessedTime(); + } + + @Override + public void setMaxInactiveInterval(Duration interval) { + this.delegate.setMaxInactiveInterval(interval); + this.maxInactiveIntervalChanged = true; + flushImmediateIfNecessary(); + } + + @Override + public Duration getMaxInactiveInterval() { + return this.delegate.getMaxInactiveInterval(); + } + + @Override + public T getAttribute(String attributeName) { + T attributeValue = this.delegate.getAttribute(attributeName); + if (attributeValue != null + && Hazelcast4IndexedSessionRepository.this.saveMode.equals(SaveMode.ON_GET_ATTRIBUTE)) { + this.delta.put(attributeName, attributeValue); + } + return attributeValue; + } + + @Override + public Set getAttributeNames() { + return this.delegate.getAttributeNames(); + } + + @Override + public void setAttribute(String attributeName, Object attributeValue) { + this.delegate.setAttribute(attributeName, attributeValue); + this.delta.put(attributeName, attributeValue); + if (SPRING_SECURITY_CONTEXT.equals(attributeName)) { + Map indexes = Hazelcast4IndexedSessionRepository.this.indexResolver + .resolveIndexesFor(this); + String principal = (attributeValue != null) ? indexes.get(PRINCIPAL_NAME_INDEX_NAME) : null; + this.delegate.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal); + } + flushImmediateIfNecessary(); + } + + @Override + public void removeAttribute(String attributeName) { + setAttribute(attributeName, null); + } + + MapSession getDelegate() { + return this.delegate; + } + + boolean hasChanges() { + return (this.lastAccessedTimeChanged || this.maxInactiveIntervalChanged || !this.delta.isEmpty()); + } + + void clearChangeFlags() { + this.isNew = false; + this.lastAccessedTimeChanged = false; + this.sessionIdChanged = false; + this.maxInactiveIntervalChanged = false; + this.delta.clear(); + } + + private void flushImmediateIfNecessary() { + if (Hazelcast4IndexedSessionRepository.this.flushMode == FlushMode.IMMEDIATE) { + Hazelcast4IndexedSessionRepository.this.save(this); + } + } + + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4PrincipalNameExtractor.java b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4PrincipalNameExtractor.java new file mode 100644 index 00000000..b879bd5f --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4PrincipalNameExtractor.java @@ -0,0 +1,43 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import com.hazelcast.query.extractor.ValueCollector; +import com.hazelcast.query.extractor.ValueExtractor; + +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.MapSession; + +/** + * Hazelcast {@link ValueExtractor} responsible for extracting principal name from the + * {@link MapSession} to be used with Hazelcast 4. + * + * @author Eleftheria Stein + * @since 2.4.0 + */ +public class Hazelcast4PrincipalNameExtractor implements ValueExtractor { + + @Override + @SuppressWarnings("unchecked") + public void extract(MapSession target, String argument, ValueCollector collector) { + String principalName = target.getAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); + if (principalName != null) { + collector.addObject(principalName); + } + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4SessionUpdateEntryProcessor.java b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4SessionUpdateEntryProcessor.java new file mode 100644 index 00000000..0dfec35e --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/main/java/org/springframework/session/hazelcast/Hazelcast4SessionUpdateEntryProcessor.java @@ -0,0 +1,86 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +import com.hazelcast.core.Offloadable; +import com.hazelcast.map.EntryProcessor; + +import org.springframework.session.MapSession; + +/** + * Hazelcast {@link EntryProcessor} responsible for handling updates to session when using + * Hazelcast 4. + * + * @author Eleftheria Stein + * @since 2.4.0 + */ +public class Hazelcast4SessionUpdateEntryProcessor implements EntryProcessor, Offloadable { + + private Instant lastAccessedTime; + + private Duration maxInactiveInterval; + + private Map delta; + + @Override + public Object process(Map.Entry entry) { + MapSession value = entry.getValue(); + if (value == null) { + return Boolean.FALSE; + } + if (this.lastAccessedTime != null) { + value.setLastAccessedTime(this.lastAccessedTime); + } + if (this.maxInactiveInterval != null) { + value.setMaxInactiveInterval(this.maxInactiveInterval); + } + if (this.delta != null) { + for (final Map.Entry attribute : this.delta.entrySet()) { + if (attribute.getValue() != null) { + value.setAttribute(attribute.getKey(), attribute.getValue()); + } + else { + value.removeAttribute(attribute.getKey()); + } + } + } + entry.setValue(value); + return Boolean.TRUE; + } + + @Override + public String getExecutorName() { + return OFFLOADABLE_EXECUTOR; + } + + void setLastAccessedTime(Instant lastAccessedTime) { + this.lastAccessedTime = lastAccessedTime; + } + + void setMaxInactiveInterval(Duration maxInactiveInterval) { + this.maxInactiveInterval = maxInactiveInterval; + } + + void setDelta(Map delta) { + this.delta = delta; + } + +} diff --git a/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/Hazelcast4IndexedSessionRepositoryTests.java b/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/Hazelcast4IndexedSessionRepositoryTests.java new file mode 100644 index 00000000..f457c8fa --- /dev/null +++ b/spring-session-hazelcast/hazelcast4/src/test/java/org/springframework/session/hazelcast/Hazelcast4IndexedSessionRepositoryTests.java @@ -0,0 +1,472 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.EntryProcessor; +import com.hazelcast.map.IMap; +import com.hazelcast.map.listener.MapListener; +import com.hazelcast.query.impl.predicates.EqualPredicate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.FlushMode; +import org.springframework.session.MapSession; +import org.springframework.session.SaveMode; +import org.springframework.session.hazelcast.Hazelcast4IndexedSessionRepository.HazelcastSession; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +/** + * Tests for {@link Hazelcast4IndexedSessionRepository}. + * + * @author Eleftheria Stein + */ +class Hazelcast4IndexedSessionRepositoryTests { + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + private HazelcastInstance hazelcastInstance = mock(HazelcastInstance.class); + + @SuppressWarnings("unchecked") + private IMap sessions = mock(IMap.class); + + private Hazelcast4IndexedSessionRepository repository; + + @BeforeEach + void setUp() { + given(this.hazelcastInstance.getMap(anyString())).willReturn(this.sessions); + this.repository = new Hazelcast4IndexedSessionRepository(this.hazelcastInstance); + this.repository.init(); + } + + @Test + void constructorNullHazelcastInstance() { + assertThatIllegalArgumentException().isThrownBy(() -> new Hazelcast4IndexedSessionRepository(null)) + .withMessage("HazelcastInstance must not be null"); + } + + @Test + void setSaveModeNull() { + assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSaveMode(null)) + .withMessage("saveMode must not be null"); + } + + @Test + void createSessionDefaultMaxInactiveInterval() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + HazelcastSession session = this.repository.createSession(); + + assertThat(session.getMaxInactiveInterval()).isEqualTo(new MapSession().getMaxInactiveInterval()); + verifyZeroInteractions(this.sessions); + } + + @Test + void createSessionCustomMaxInactiveInterval() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + int interval = 1; + this.repository.setDefaultMaxInactiveInterval(interval); + + HazelcastSession session = this.repository.createSession(); + + assertThat(session.getMaxInactiveInterval()).isEqualTo(Duration.ofSeconds(interval)); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveNewFlushModeOnSave() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + HazelcastSession session = this.repository.createSession(); + verifyZeroInteractions(this.sessions); + + this.repository.save(session); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveNewFlushModeImmediate() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + this.repository.setFlushMode(FlushMode.IMMEDIATE); + + HazelcastSession session = this.repository.createSession(); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveUpdatedAttributeFlushModeOnSave() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + HazelcastSession session = this.repository.createSession(); + session.setAttribute("testName", "testValue"); + verifyZeroInteractions(this.sessions); + + this.repository.save(session); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveUpdatedAttributeFlushModeImmediate() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + this.repository.setFlushMode(FlushMode.IMMEDIATE); + + HazelcastSession session = this.repository.createSession(); + session.setAttribute("testName", "testValue"); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verify(this.sessions, times(1)).executeOnKey(eq(session.getId()), any(EntryProcessor.class)); + + this.repository.save(session); + verifyZeroInteractions(this.sessions); + } + + @Test + void removeAttributeFlushModeOnSave() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + HazelcastSession session = this.repository.createSession(); + session.removeAttribute("testName"); + verifyZeroInteractions(this.sessions); + + this.repository.save(session); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verifyZeroInteractions(this.sessions); + } + + @Test + void removeAttributeFlushModeImmediate() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + this.repository.setFlushMode(FlushMode.IMMEDIATE); + + HazelcastSession session = this.repository.createSession(); + session.removeAttribute("testName"); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verify(this.sessions, times(1)).executeOnKey(eq(session.getId()), any(EntryProcessor.class)); + + this.repository.save(session); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveUpdatedLastAccessedTimeFlushModeOnSave() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + HazelcastSession session = this.repository.createSession(); + session.setLastAccessedTime(Instant.now()); + verifyZeroInteractions(this.sessions); + + this.repository.save(session); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveUpdatedLastAccessedTimeFlushModeImmediate() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + this.repository.setFlushMode(FlushMode.IMMEDIATE); + + HazelcastSession session = this.repository.createSession(); + session.setLastAccessedTime(Instant.now()); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verify(this.sessions, times(1)).executeOnKey(eq(session.getId()), any(EntryProcessor.class)); + + this.repository.save(session); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveUpdatedMaxInactiveIntervalInSecondsFlushModeOnSave() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + HazelcastSession session = this.repository.createSession(); + session.setMaxInactiveInterval(Duration.ofSeconds(1)); + verifyZeroInteractions(this.sessions); + + this.repository.save(session); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveUpdatedMaxInactiveIntervalInSecondsFlushModeImmediate() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + this.repository.setFlushMode(FlushMode.IMMEDIATE); + + HazelcastSession session = this.repository.createSession(); + String sessionId = session.getId(); + session.setMaxInactiveInterval(Duration.ofSeconds(1)); + verify(this.sessions, times(1)).set(eq(sessionId), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + verify(this.sessions).setTtl(eq(sessionId), anyLong(), any()); + verify(this.sessions, times(1)).executeOnKey(eq(sessionId), any(EntryProcessor.class)); + + this.repository.save(session); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveUnchangedFlushModeOnSave() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + HazelcastSession session = this.repository.createSession(); + this.repository.save(session); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + + this.repository.save(session); + verifyZeroInteractions(this.sessions); + } + + @Test + void saveUnchangedFlushModeImmediate() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + this.repository.setFlushMode(FlushMode.IMMEDIATE); + + HazelcastSession session = this.repository.createSession(); + verify(this.sessions, times(1)).set(eq(session.getId()), eq(session.getDelegate()), isA(Long.class), + eq(TimeUnit.SECONDS)); + + this.repository.save(session); + verifyZeroInteractions(this.sessions); + } + + @Test + void getSessionNotFound() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + String sessionId = "testSessionId"; + + HazelcastSession session = this.repository.findById(sessionId); + + assertThat(session).isNull(); + verify(this.sessions, times(1)).get(eq(sessionId)); + verifyZeroInteractions(this.sessions); + } + + @Test + void getSessionExpired() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + MapSession expired = new MapSession(); + expired.setLastAccessedTime(Instant.now().minusSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS + 1)); + given(this.sessions.get(eq(expired.getId()))).willReturn(expired); + + HazelcastSession session = this.repository.findById(expired.getId()); + + assertThat(session).isNull(); + verify(this.sessions, times(1)).get(eq(expired.getId())); + verify(this.sessions, times(1)).remove(eq(expired.getId())); + verifyZeroInteractions(this.sessions); + } + + @Test + void getSessionFound() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + MapSession saved = new MapSession(); + saved.setAttribute("savedName", "savedValue"); + given(this.sessions.get(eq(saved.getId()))).willReturn(saved); + + HazelcastSession session = this.repository.findById(saved.getId()); + + assertThat(session.getId()).isEqualTo(saved.getId()); + assertThat(session.getAttribute("savedName")).isEqualTo("savedValue"); + verify(this.sessions, times(1)).get(eq(saved.getId())); + verifyZeroInteractions(this.sessions); + } + + @Test + void delete() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + String sessionId = "testSessionId"; + + this.repository.deleteById(sessionId); + + verify(this.sessions, times(1)).remove(eq(sessionId)); + verifyZeroInteractions(this.sessions); + } + + @Test + void findByIndexNameAndIndexValueUnknownIndexName() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + String indexValue = "testIndexValue"; + + Map sessions = this.repository.findByIndexNameAndIndexValue("testIndexName", + indexValue); + + assertThat(sessions).isEmpty(); + verifyZeroInteractions(this.sessions); + } + + @Test + void findByIndexNameAndIndexValuePrincipalIndexNameNotFound() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + String principal = "username"; + + Map sessions = this.repository + .findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principal); + + assertThat(sessions).isEmpty(); + verify(this.sessions, times(1)).values(isA(EqualPredicate.class)); + verifyZeroInteractions(this.sessions); + } + + @Test + void findByIndexNameAndIndexValuePrincipalIndexNameFound() { + verify(this.sessions, times(1)).addEntryListener(any(MapListener.class), anyBoolean()); + + String principal = "username"; + Authentication authentication = new UsernamePasswordAuthenticationToken(principal, "notused", + AuthorityUtils.createAuthorityList("ROLE_USER")); + List saved = new ArrayList<>(2); + MapSession saved1 = new MapSession(); + saved1.setAttribute(SPRING_SECURITY_CONTEXT, authentication); + saved.add(saved1); + MapSession saved2 = new MapSession(); + saved2.setAttribute(SPRING_SECURITY_CONTEXT, authentication); + saved.add(saved2); + given(this.sessions.values(isA(EqualPredicate.class))).willReturn(saved); + + Map sessions = this.repository + .findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principal); + + assertThat(sessions).hasSize(2); + verify(this.sessions, times(1)).values(isA(EqualPredicate.class)); + verifyZeroInteractions(this.sessions); + } + + @Test // gh-1120 + void getAttributeNamesAndRemove() { + HazelcastSession session = this.repository.createSession(); + session.setAttribute("attribute1", "value1"); + session.setAttribute("attribute2", "value2"); + + for (String attributeName : session.getAttributeNames()) { + session.removeAttribute(attributeName); + } + + assertThat(session.getAttributeNames()).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + void saveWithSaveModeOnSetAttribute() { + verify(this.sessions).addEntryListener(any(MapListener.class), anyBoolean()); + this.repository.setSaveMode(SaveMode.ON_SET_ATTRIBUTE); + MapSession delegate = new MapSession(); + delegate.setAttribute("attribute1", "value1"); + delegate.setAttribute("attribute2", "value2"); + delegate.setAttribute("attribute3", "value3"); + HazelcastSession session = this.repository.new HazelcastSession(delegate, false); + session.getAttribute("attribute2"); + session.setAttribute("attribute3", "value4"); + this.repository.save(session); + ArgumentCaptor captor = ArgumentCaptor + .forClass(Hazelcast4SessionUpdateEntryProcessor.class); + verify(this.sessions).executeOnKey(eq(session.getId()), captor.capture()); + assertThat((Map) ReflectionTestUtils.getField(captor.getValue(), "delta")).hasSize(1); + verifyZeroInteractions(this.sessions); + } + + @Test + @SuppressWarnings("unchecked") + void saveWithSaveModeOnGetAttribute() { + verify(this.sessions).addEntryListener(any(MapListener.class), anyBoolean()); + this.repository.setSaveMode(SaveMode.ON_GET_ATTRIBUTE); + MapSession delegate = new MapSession(); + delegate.setAttribute("attribute1", "value1"); + delegate.setAttribute("attribute2", "value2"); + delegate.setAttribute("attribute3", "value3"); + HazelcastSession session = this.repository.new HazelcastSession(delegate, false); + session.getAttribute("attribute2"); + session.setAttribute("attribute3", "value4"); + this.repository.save(session); + ArgumentCaptor captor = ArgumentCaptor + .forClass(Hazelcast4SessionUpdateEntryProcessor.class); + verify(this.sessions).executeOnKey(eq(session.getId()), captor.capture()); + assertThat((Map) ReflectionTestUtils.getField(captor.getValue(), "delta")).hasSize(2); + verifyZeroInteractions(this.sessions); + } + + @Test + @SuppressWarnings("unchecked") + void saveWithSaveModeAlways() { + verify(this.sessions).addEntryListener(any(MapListener.class), anyBoolean()); + this.repository.setSaveMode(SaveMode.ALWAYS); + MapSession delegate = new MapSession(); + delegate.setAttribute("attribute1", "value1"); + delegate.setAttribute("attribute2", "value2"); + delegate.setAttribute("attribute3", "value3"); + HazelcastSession session = this.repository.new HazelcastSession(delegate, false); + session.getAttribute("attribute2"); + session.setAttribute("attribute3", "value4"); + this.repository.save(session); + ArgumentCaptor captor = ArgumentCaptor + .forClass(Hazelcast4SessionUpdateEntryProcessor.class); + verify(this.sessions).executeOnKey(eq(session.getId()), captor.capture()); + assertThat((Map) ReflectionTestUtils.getField(captor.getValue(), "delta")).hasSize(3); + verifyZeroInteractions(this.sessions); + } + +} diff --git a/spring-session-hazelcast/spring-session-hazelcast.gradle b/spring-session-hazelcast/spring-session-hazelcast.gradle index b3a67502..bf018d68 100644 --- a/spring-session-hazelcast/spring-session-hazelcast.gradle +++ b/spring-session-hazelcast/spring-session-hazelcast.gradle @@ -1,17 +1,29 @@ apply plugin: 'io.spring.convention.spring-module' +configurations { + hazelcast4 +} + dependencies { compile project(':spring-session-core') compile "com.hazelcast:hazelcast" compile "javax.annotation:javax.annotation-api" compile "org.springframework:spring-context" + hazelcast4(project(path: ":hazelcast4", configuration: 'classesOnlyElements')) + compileOnly(project(":hazelcast4")) + testCompile "javax.servlet:javax.servlet-api" testCompile "org.springframework:spring-web" testCompile "org.springframework.security:spring-security-core" testCompile "org.junit.jupiter:junit-jupiter-api" + testRuntime project(':hazelcast4') testRuntime "org.junit.jupiter:junit-jupiter-engine" integrationTestCompile "com.hazelcast:hazelcast-client" integrationTestCompile "org.testcontainers:testcontainers" } + +jar { + from configurations.hazelcast4 +} diff --git a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSession.java b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSession.java index 5857e15b..8953e3d8 100644 --- a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSession.java +++ b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSession.java @@ -33,7 +33,6 @@ import org.springframework.session.Session; import org.springframework.session.SessionRepository; import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; import org.springframework.session.hazelcast.HazelcastFlushMode; -import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository; import org.springframework.session.web.http.SessionRepositoryFilter; /** @@ -68,7 +67,7 @@ import org.springframework.session.web.http.SessionRepositoryFilter; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented -@Import(HazelcastHttpSessionConfiguration.class) +@Import(EnableHazelcastHttpSessionSelector.class) @Configuration(proxyBeanMethods = false) public @interface EnableHazelcastHttpSession { @@ -81,11 +80,10 @@ public @interface EnableHazelcastHttpSession { /** * This is the name of the Map that will be used in Hazelcast to store the session - * data. Default is - * {@link HazelcastIndexedSessionRepository#DEFAULT_SESSION_MAP_NAME}. + * data. Default is "spring:session:sessions". * @return the name of the Map to store the sessions in Hazelcast */ - String sessionMapName() default HazelcastIndexedSessionRepository.DEFAULT_SESSION_MAP_NAME; + String sessionMapName() default "spring:session:sessions"; /** * Flush mode for the Hazelcast sessions. The default is {@code ON_SAVE} which only diff --git a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSessionSelector.java b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSessionSelector.java new file mode 100644 index 00000000..84e09c93 --- /dev/null +++ b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/EnableHazelcastHttpSessionSelector.java @@ -0,0 +1,48 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast.config.annotation.web.http; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.context.annotation.ImportSelector; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ClassUtils; + +/** + * Dynamically determines which Hazelcast configuration class to include. + * + * @author Eleftheria Stein + * @since 2.4.0 + */ +class EnableHazelcastHttpSessionSelector implements ImportSelector { + + @Override + public String[] selectImports(AnnotationMetadata importingClassMetadata) { + ClassLoader classLoader = getClass().getClassLoader(); + boolean hazelcast4OnClasspath = ClassUtils.isPresent("com.hazelcast.map.IMap", classLoader); + List classNames = new ArrayList<>(1); + if (hazelcast4OnClasspath) { + classNames.add(Hazelcast4HttpSessionConfiguration.class.getName()); + } + else { + classNames.add(HazelcastHttpSessionConfiguration.class.getName()); + } + return classNames.toArray(new String[0]); + } + +} diff --git a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/Hazelcast4HttpSessionConfiguration.java b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/Hazelcast4HttpSessionConfiguration.java new file mode 100644 index 00000000..fa211174 --- /dev/null +++ b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/config/annotation/web/http/Hazelcast4HttpSessionConfiguration.java @@ -0,0 +1,161 @@ +/* + * Copyright 2014-2020 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.springframework.session.hazelcast.config.annotation.web.http; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.hazelcast.core.HazelcastInstance; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportAware; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.session.FlushMode; +import org.springframework.session.IndexResolver; +import org.springframework.session.MapSession; +import org.springframework.session.SaveMode; +import org.springframework.session.Session; +import org.springframework.session.config.SessionRepositoryCustomizer; +import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration; +import org.springframework.session.hazelcast.Hazelcast4IndexedSessionRepository; +import org.springframework.session.hazelcast.HazelcastFlushMode; +import org.springframework.session.hazelcast.config.annotation.SpringSessionHazelcastInstance; +import org.springframework.session.web.http.SessionRepositoryFilter; +import org.springframework.util.StringUtils; + +/** + * Exposes the {@link SessionRepositoryFilter} as a bean named + * {@code springSessionRepositoryFilter}. In order to use this a single + * {@link HazelcastInstance} configured for Hazelcast 4 must be exposed as a Bean. + * + * @author Eleftheria Stein + * @since 2.4.0 + * @see EnableHazelcastHttpSession + */ +@Configuration(proxyBeanMethods = false) +public class Hazelcast4HttpSessionConfiguration extends SpringHttpSessionConfiguration implements ImportAware { + + private Integer maxInactiveIntervalInSeconds = MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS; + + private String sessionMapName = Hazelcast4IndexedSessionRepository.DEFAULT_SESSION_MAP_NAME; + + private FlushMode flushMode = FlushMode.ON_SAVE; + + private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + + private HazelcastInstance hazelcastInstance; + + private ApplicationEventPublisher applicationEventPublisher; + + private IndexResolver indexResolver; + + private List> sessionRepositoryCustomizers; + + @Bean + public Hazelcast4IndexedSessionRepository sessionRepository() { + Hazelcast4IndexedSessionRepository sessionRepository = new Hazelcast4IndexedSessionRepository( + this.hazelcastInstance); + sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher); + if (this.indexResolver != null) { + sessionRepository.setIndexResolver(this.indexResolver); + } + if (StringUtils.hasText(this.sessionMapName)) { + sessionRepository.setSessionMapName(this.sessionMapName); + } + sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds); + sessionRepository.setFlushMode(this.flushMode); + sessionRepository.setSaveMode(this.saveMode); + this.sessionRepositoryCustomizers + .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository)); + return sessionRepository; + } + + public void setMaxInactiveIntervalInSeconds(int maxInactiveIntervalInSeconds) { + this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds; + } + + public void setSessionMapName(String sessionMapName) { + this.sessionMapName = sessionMapName; + } + + @Deprecated + public void setHazelcastFlushMode(HazelcastFlushMode hazelcastFlushMode) { + setFlushMode(hazelcastFlushMode.getFlushMode()); + } + + public void setFlushMode(FlushMode flushMode) { + this.flushMode = flushMode; + } + + public void setSaveMode(SaveMode saveMode) { + this.saveMode = saveMode; + } + + @Autowired + public void setHazelcastInstance( + @SpringSessionHazelcastInstance ObjectProvider springSessionHazelcastInstance, + ObjectProvider hazelcastInstance) { + HazelcastInstance hazelcastInstanceToUse = springSessionHazelcastInstance.getIfAvailable(); + if (hazelcastInstanceToUse == null) { + hazelcastInstanceToUse = hazelcastInstance.getObject(); + } + this.hazelcastInstance = hazelcastInstanceToUse; + } + + @Autowired + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + + @Autowired(required = false) + public void setIndexResolver(IndexResolver indexResolver) { + this.indexResolver = indexResolver; + } + + @Autowired(required = false) + public void setSessionRepositoryCustomizer( + ObjectProvider> sessionRepositoryCustomizers) { + this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList()); + } + + @Override + @SuppressWarnings("deprecation") + public void setImportMetadata(AnnotationMetadata importMetadata) { + Map attributeMap = importMetadata + .getAnnotationAttributes(EnableHazelcastHttpSession.class.getName()); + AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributeMap); + this.maxInactiveIntervalInSeconds = attributes.getNumber("maxInactiveIntervalInSeconds"); + String sessionMapNameValue = attributes.getString("sessionMapName"); + if (StringUtils.hasText(sessionMapNameValue)) { + this.sessionMapName = sessionMapNameValue; + } + FlushMode flushMode = attributes.getEnum("flushMode"); + HazelcastFlushMode hazelcastFlushMode = attributes.getEnum("hazelcastFlushMode"); + if (flushMode == FlushMode.ON_SAVE && hazelcastFlushMode != HazelcastFlushMode.ON_SAVE) { + flushMode = hazelcastFlushMode.getFlushMode(); + } + this.flushMode = flushMode; + this.saveMode = attributes.getEnum("saveMode"); + } + +}