diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f1c13f7..8aaa2c2c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,5 +63,6 @@ org-springframework-spring-framework-bom = "org.springframework:spring-framework org-springframework-boot-spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "org-springframework-boot" } org-springframework-boot-spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "org-springframework-boot" } org-testcontainers-testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "org-testcontainers" } +org-awaitility-awaitility = "org.awaitility:awaitility:4.2.0" [plugins] diff --git a/spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java b/spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java index f4d7e87b..3d79d86c 100644 --- a/spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java +++ b/spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java @@ -38,6 +38,15 @@ public class PrincipalNameIndexResolver extends SingleIndexRe super(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); } + /** + * Create a new instance specifying the name of the index to be resolved. + * @param indexName the name of the index to be resolved + * @since 3.3 + */ + public PrincipalNameIndexResolver(String indexName) { + super(indexName); + } + public String resolveIndexValueFor(S session) { String principalName = session.getAttribute(getIndexName()); if (principalName != null) { diff --git a/spring-session-core/src/main/java/org/springframework/session/ReactiveFindByIndexNameSessionRepository.java b/spring-session-core/src/main/java/org/springframework/session/ReactiveFindByIndexNameSessionRepository.java new file mode 100644 index 00000000..d5b3ab24 --- /dev/null +++ b/spring-session-core/src/main/java/org/springframework/session/ReactiveFindByIndexNameSessionRepository.java @@ -0,0 +1,60 @@ +/* + * Copyright 2014-2023 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; + +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * Allow finding sessions by the specified index name and index value. + * + * @param the type of Session being managed by this + * {@link ReactiveFindByIndexNameSessionRepository} + * @author Marcus da Coregio + * @since 3.3 + */ +public interface ReactiveFindByIndexNameSessionRepository { + + /** + * A session index that contains the current principal name (i.e. username). + *

+ * It is the responsibility of the developer to ensure the index is populated since + * Spring Session is not aware of the authentication mechanism being used. + */ + String PRINCIPAL_NAME_INDEX_NAME = "PRINCIPAL_NAME_INDEX_NAME"; + + /** + * Find a {@link Map} of the session id to the {@link Session} of all sessions that + * contain the specified index name index value. + * @param indexName the name of the index (i.e. {@link #PRINCIPAL_NAME_INDEX_NAME}) + * @param indexValue the value of the index to search for. + * @return a {@code Map} (never {@code null}) of the session id to the {@code Session} + */ + Mono> findByIndexNameAndIndexValue(String indexName, String indexValue); + + /** + * A shortcut for {@link #findByIndexNameAndIndexValue(String, String)} that uses + * {@link #PRINCIPAL_NAME_INDEX_NAME} for the index name. + * @param principalName the principal name + * @return a {@code Map} (never {@code null}) of the session id to the {@code Session} + */ + default Mono> findByPrincipalName(String principalName) { + return findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName); + } + +} diff --git a/spring-session-data-redis/spring-session-data-redis.gradle b/spring-session-data-redis/spring-session-data-redis.gradle index 8fc071a2..fa027249 100644 --- a/spring-session-data-redis/spring-session-data-redis.gradle +++ b/spring-session-data-redis/spring-session-data-redis.gradle @@ -21,8 +21,10 @@ dependencies { testImplementation "org.springframework:spring-web" testImplementation "org.springframework.security:spring-security-core" testImplementation "org.junit.jupiter:junit-jupiter-api" + testImplementation "org.awaitility:awaitility" + testImplementation "io.lettuce:lettuce-core" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" - integrationTestCompile "io.lettuce:lettuce-core" integrationTestCompile "org.testcontainers:testcontainers" + integrationTestCompile "com.redis:testcontainers-redis:1.7.0" } diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java index f5616ac0..b4d9a2aa 100644 --- a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java @@ -16,7 +16,7 @@ package org.springframework.session.data.redis; -import org.testcontainers.containers.GenericContainer; +import com.redis.testcontainers.RedisContainer; import org.springframework.context.annotation.Bean; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; @@ -34,16 +34,17 @@ public abstract class AbstractRedisITests { protected static class BaseConfig { @Bean - public GenericContainer redisContainer() { - GenericContainer redisContainer = new GenericContainer(DOCKER_IMAGE).withExposedPorts(6379); + public RedisContainer redisContainer() { + RedisContainer redisContainer = new RedisContainer( + RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG)); redisContainer.start(); return redisContainer; } @Bean - public LettuceConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisContainer().getHost(), - redisContainer().getFirstMappedPort()); + public LettuceConnectionFactory redisConnectionFactory(RedisContainer redisContainer) { + RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisContainer.getHost(), + redisContainer.getFirstMappedPort()); return new LettuceConnectionFactory(configuration); } diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryConfigurationITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryConfigurationITests.java new file mode 100644 index 00000000..11aa3c95 --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryConfigurationITests.java @@ -0,0 +1,184 @@ +/* + * Copyright 2014-2023 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.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +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.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.data.SessionEventRegistry; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession; +import org.springframework.session.data.redis.config.ConfigureReactiveRedisAction; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@ExtendWith(SpringExtension.class) +class ReactiveRedisIndexedSessionRepositoryConfigurationITests { + + ReactiveRedisIndexedSessionRepository repository; + + ReactiveRedisOperations sessionRedisOperations; + + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + + SecurityContext securityContext; + + @BeforeEach + void setup() { + this.securityContext = SecurityContextHolder.createEmptyContext(); + this.securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("username-" + UUID.randomUUID(), + "na", AuthorityUtils.createAuthorityList("ROLE_USER"))); + } + + @Test + void cleanUpTaskWhenSessionIsExpiredThenAllRelatedKeysAreDeleted() { + registerConfig(OneSecCleanUpIntervalConfig.class); + RedisSession session = this.repository.createSession().block(); + session.setAttribute("SPRING_SECURITY_CONTEXT", this.securityContext); + this.repository.save(session).block(); + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> { + assertThat(this.repository.findById(session.getId()).block()).isNull(); + Boolean hasSessionKey = this.sessionRedisOperations.hasKey("spring:session:sessions:" + session.getId()) + .block(); + Boolean hasSessionIndexesKey = this.sessionRedisOperations + .hasKey("spring:session:sessions:" + session.getId() + ":idx") + .block(); + Boolean hasPrincipalIndexKey = this.sessionRedisOperations + .hasKey("spring:session:sessions:index:" + + ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":" + + this.securityContext.getAuthentication().getName()) + .block(); + Long expirationsSize = this.sessionRedisOperations.opsForZSet() + .size("spring:session:sessions:expirations") + .block(); + assertThat(hasSessionKey).isFalse(); + assertThat(hasSessionIndexesKey).isFalse(); + assertThat(hasPrincipalIndexKey).isFalse(); + assertThat(expirationsSize).isZero(); + }); + } + + @Test + void onSessionCreatedWhenUsingJsonSerializerThenEventDeserializedCorrectly() throws InterruptedException { + registerConfig(SessionEventRegistryJsonSerializerConfig.class); + RedisSession session = this.repository.createSession().block(); + this.repository.save(session).block(); + SessionEventRegistry registry = this.context.getBean(SessionEventRegistry.class); + SessionCreatedEvent event = registry.getEvent(session.getId()); + Session eventSession = event.getSession(); + assertThat(eventSession).usingRecursiveComparison() + .withComparatorForFields(new InstantComparator(), "cached.creationTime", "cached.lastAccessedTime") + .isEqualTo(session); + } + + @Test + void sessionExpiredWhenNoCleanUpTaskAndNoKeyspaceEventsThenNoCleanup() { + registerConfig(DisableCleanupTaskAndNoKeyspaceEventsConfig.class); + RedisSession session = this.repository.createSession().block(); + this.repository.save(session).block(); + await().during(Duration.ofSeconds(3)).untilAsserted(() -> { + Boolean exists = this.sessionRedisOperations.hasKey("spring:session:sessions:" + session.getId()).block(); + assertThat(exists).isTrue(); + }); + } + + private void registerConfig(Class clazz) { + this.context.register(clazz); + this.context.refresh(); + this.repository = this.context.getBean(ReactiveRedisIndexedSessionRepository.class); + this.sessionRedisOperations = this.repository.getSessionRedisOperations(); + } + + static class InstantComparator implements Comparator { + + @Override + public int compare(Instant o1, Instant o2) { + return o1.truncatedTo(ChronoUnit.SECONDS).compareTo(o2.truncatedTo(ChronoUnit.SECONDS)); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedWebSession(maxInactiveIntervalInSeconds = 1) + @Import(AbstractRedisITests.BaseConfig.class) + static class OneSecCleanUpIntervalConfig { + + @Bean + ReactiveSessionRepositoryCustomizer customizer() { + return (sessionRepository) -> sessionRepository.setCleanupInterval(Duration.ofSeconds(1)); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedWebSession + @Import(AbstractRedisITests.BaseConfig.class) + static class SessionEventRegistryJsonSerializerConfig { + + @Bean + SessionEventRegistry sessionEventRegistry() { + return new SessionEventRegistry(); + } + + @Bean + RedisSerializer springSessionDefaultRedisSerializer() { + return RedisSerializer.json(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedWebSession(maxInactiveIntervalInSeconds = 1) + @Import(AbstractRedisITests.BaseConfig.class) + static class DisableCleanupTaskAndNoKeyspaceEventsConfig { + + @Bean + ReactiveSessionRepositoryCustomizer customizer() { + return ReactiveRedisIndexedSessionRepository::disableCleanupTask; + } + + @Bean + ConfigureReactiveRedisAction configureReactiveRedisAction() { + return (connection) -> connection.serverCommands().setConfig("notify-keyspace-events", "").then(); + } + + } + +} diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryITests.java new file mode 100644 index 00000000..b59dc533 --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepositoryITests.java @@ -0,0 +1,728 @@ +/* + * Copyright 2014-2023 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.data.redis; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +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.context.annotation.Import; +import org.springframework.data.redis.core.ReactiveRedisOperations; +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.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.data.SessionEventRegistry; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession; +import org.springframework.session.data.redis.config.annotation.SpringSessionRedisOperations; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionExpiredEvent; +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; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.awaitility.Awaitility.await; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +@SuppressWarnings({ "ConstantConditions" }) +class ReactiveRedisIndexedSessionRepositoryITests { + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + private static final String INDEX_NAME = ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME; + + @Autowired + private ReactiveRedisIndexedSessionRepository repository; + + @Autowired + private SessionEventRegistry eventRegistry; + + @SpringSessionRedisOperations + private ReactiveRedisOperations redis; + + private SecurityContext context; + + private SecurityContext changedContext; + + @BeforeEach + void setup() { + this.context = SecurityContextHolder.createEmptyContext(); + this.context.setAuthentication(new UsernamePasswordAuthenticationToken("username-" + UUID.randomUUID(), "na", + AuthorityUtils.createAuthorityList("ROLE_USER"))); + + this.changedContext = SecurityContextHolder.createEmptyContext(); + this.changedContext.setAuthentication(new UsernamePasswordAuthenticationToken( + "changedContext-" + UUID.randomUUID(), "na", AuthorityUtils.createAuthorityList("ROLE_USER"))); + } + + @Test + void findByIdWhenSavedThenFound() { + RedisSession session = this.repository.createSession().block(); + session.setAttribute("foo", "bar"); + this.repository.save(session).block(); + RedisSession savedSession = this.repository.findById(session.getId()).block(); + assertThat(savedSession).isNotNull(); + assertThat(savedSession.getId()).isEqualTo(session.getId()); + assertThat(savedSession.getAttribute("foo")).isEqualTo("bar"); + } + + @Test + void saveWhenHasSecurityContextAttributeThenPrincipalIndexKeySaved() { + RedisSession session = this.repository.createSession().block(); + session.setAttribute("foo", "bar"); + + String username = "user"; + Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(username, "password", + AuthorityUtils.createAuthorityList("ROLE_USER")); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + session.setAttribute(SPRING_SECURITY_CONTEXT, context); + this.repository.save(session).block(); + + String usernameSessionKey = "spring:session:sessions:index:" + + ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":" + username; + Boolean sessionExistsOnPrincipalKey = this.redis.opsForSet() + .isMember(usernameSessionKey, session.getId()) + .block(); + assertThat(sessionExistsOnPrincipalKey).isTrue(); + } + + @Test + void saveWhenSuccessThenSessionCreatedEvent() throws InterruptedException { + RedisSession session = this.repository.createSession().block(); + session.setAttribute("foo", "bar"); + + this.repository.save(session).block(); + + SessionCreatedEvent event = this.eventRegistry.getEvent(session.getId()); + assertThat(event).isNotNull(); + RedisSession eventSession = event.getSession(); + compareSessions(session, eventSession); + } + + @Test + void findByPrincipalNameWhenExistsThenReturn() { + RedisSession session = this.repository.createSession().block(); + String principalName = "principal"; + session.setAttribute(ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principalName); + + this.repository.save(session).block(); + + Map principalSessions = this.repository + .findByIndexNameAndIndexValue(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, + principalName) + .block(); + + assertThat(principalSessions).hasSize(1); + assertThat(principalSessions.keySet()).containsOnly(session.getId()); + + this.repository.deleteById(session.getId()).block(); + + principalSessions = this.repository + .findByIndexNameAndIndexValue(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, + principalName) + .block(); + assertThat(principalSessions).isEmpty(); + } + + @Test + void findByPrincipalNameWhenExpireKeyEventThenRemovesIndexAndSessionExpiredEvent() { + String principalName = "findByPrincipalNameExpireRemovesIndex" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + + this.eventRegistry.clear(); + String key = "spring:session:sessions:expires:" + toSave.getId(); + assertThat(this.redis.expire(key, Duration.ofSeconds(1)).block()).isTrue(); + + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> { + SessionExpiredEvent event = this.eventRegistry.getEvent(toSave.getId()); + RedisSession eventSession = event.getSession(); + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).hasSize(0); + assertThat(findByPrincipalName.keySet()).doesNotContain(toSave.getId()); + assertThat(event).isNotNull(); + compareSessions(toSave, eventSession); + }); + } + + private static void compareSessions(RedisSession session1, RedisSession session2) { + assertThat(session2.getCreationTime().truncatedTo(ChronoUnit.SECONDS)) + .isEqualTo(session1.getCreationTime().truncatedTo(ChronoUnit.SECONDS)); + assertThat(session2.getMaxInactiveInterval().truncatedTo(ChronoUnit.SECONDS)) + .isEqualTo(session1.getMaxInactiveInterval().truncatedTo(ChronoUnit.SECONDS)); + assertThat(session2.getId()).isEqualTo(session1.getId()); + assertThat(session2.getAttributeNames()).isEqualTo(session1.getAttributeNames()); + } + + @Test + void findByPrincipalNameWhenDeletedKeyEventThenRemovesIndex() { + String principalName = "findByPrincipalNameExpireRemovesIndex" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + + String key = "spring:session:sessions:expires:" + toSave.getId(); + assertThat(this.redis.delete(key).block()).isEqualTo(1); + + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> { + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).hasSize(0); + assertThat(findByPrincipalName.keySet()).doesNotContain(toSave.getId()); + SessionDeletedEvent event = this.eventRegistry.getEvent(toSave.getId()); + assertThat(event).isNotNull(); + RedisSession eventSession = event.getSession(); + compareSessions(toSave, eventSession); + }); + } + + @Test + void findByPrincipalNameWhenNoPrincipalNameChangeThenKeepIndex() { + String principalName = "findByPrincipalNameNoPrincipalNameChange" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByPrincipalNameWhenNoPrincipalNameChangeAndFindByIdThenKeepIndex() { + String principalName = "findByPrincipalNameNoPrincipalNameChange" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + toSave = this.repository.findById(toSave.getId()).block(); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByPrincipalNameWhenDeletedPrincipalAttributeThenEmpty() { + String principalName = "findByDeletedPrincipalName" + UUID.randomUUID(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave).block(); + + toSave.removeAttribute(INDEX_NAME); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameWhenDeletedPrincipalAttributeAndFindByIdThenEmpty() { + String principalName = "findByDeletedPrincipalName" + UUID.randomUUID(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(INDEX_NAME, principalName); + + this.repository.save(session).block(); + session = this.repository.findById(session.getId()).block(); + + session.removeAttribute(INDEX_NAME); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameWhenChangedSecurityContextAttributeThenIndexMovedToNewPrincipal() { + String principalName = this.context.getAuthentication().getName(); + String principalNameChanged = this.changedContext.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + + session.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + @Test + void findByPrincipalNameWhenChangedSecurityContextAttributeAndFindByIdThenIndexMovedToNewPrincipal() { + String principalName = this.context.getAuthentication().getName(); + String principalNameChanged = this.changedContext.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + session = this.repository.findById(session.getId()).block(); + + session.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + @Test + void findByPrincipalNameWhenNoSecurityContextChangeThenKeepIndex() { + String principalName = this.context.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + + session.setAttribute("other", "value"); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + @Test + void findByPrincipalNameWhenNoSecurityContextChangeAndFindByIdThenKeepIndex() { + String principalName = this.context.getAuthentication().getName(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave).block(); + toSave = this.repository.findById(toSave.getId()).block(); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByPrincipalNameWhenDeletedSecurityContextAttributeThenEmpty() { + String principalName = this.context.getAuthentication().getName(); + RedisSession toSave = this.repository.createSession().block(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave).block(); + + toSave.removeAttribute(SPRING_SECURITY_CONTEXT); + this.repository.save(toSave).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameWhenDeletedSecurityContextAttributeAndFindByIdThenEmpty() { + String principalName = this.context.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + session = this.repository.findById(session.getId()).block(); + + session.removeAttribute(SPRING_SECURITY_CONTEXT); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + + .block(); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameWhenChangedPrincipalAttributeThenEmpty() { + String principalName = this.context.getAuthentication().getName(); + String principalNameChanged = this.changedContext.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + + session.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + @Test + void findByPrincipalNameWhenChangedPrincipalAttributeAndFindByIdThenEmpty() { + String principalName = this.context.getAuthentication().getName(); + String principalNameChanged = this.changedContext.getAuthentication().getName(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + session = this.repository.findById(session.getId()).block(); + + session.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(session).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(session.getId()); + } + + // gh-1791 + @Test + void changeSessionIdWhenSessionExpiredThenRemovesAllPrincipalIndex() { + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + String usernameSessionKey = "spring:session:sessions:index:" + INDEX_NAME + ":" + getSecurityName(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + String originalFindById = findById.getId(); + + assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()).contains(originalFindById); + + String changeSessionId = findById.changeSessionId(); + findById.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(findById).block(); + + assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()).contains(changeSessionId); + + String key = "spring:session:sessions:expires:" + changeSessionId; + assertThat(this.redis.expire(key, Duration.ofSeconds(1)).block()).isTrue(); // expire + // the + // key + + await().atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()) + .isEmpty()); + } + + @Test + void changeSessionIdWhenSessionDeletedThenRemovesAllPrincipalIndex() { + RedisSession session = this.repository.createSession().block(); + session.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(session).block(); + String usernameSessionKey = "spring:session:sessions:index:" + INDEX_NAME + ":" + getSecurityName(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + String originalFindById = findById.getId(); + + assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()).contains(originalFindById); + + String changeSessionId = findById.changeSessionId(); + findById.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(findById).block(); + + assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()).contains(changeSessionId); + + String key = "spring:session:sessions:expires:" + changeSessionId; + assertThat(this.redis.delete(key).block()).isEqualTo(1); + + await().atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(this.redis.opsForSet().members(usernameSessionKey).collectList().block()) + .isEmpty()); + } + + @Test + void changeSessionIdWhenPrincipalNameChangesThenNewPrincipalMapsToNewSessionId() { + String principalName = "findByChangedPrincipalName" + UUID.randomUUID(); + String principalNameChanged = "findByChangedPrincipalName" + UUID.randomUUID(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(INDEX_NAME, principalName); + + this.repository.save(session).block(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + String changeSessionId = findById.changeSessionId(); + findById.setAttribute(INDEX_NAME, principalNameChanged); + this.repository.save(findById).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(changeSessionId); + } + + // gh-1987 + @Test + void changeSessionIdWhenPrincipalNameChangesFromNullThenIndexShouldNotBeCreated() { + String principalName = null; + String principalNameChanged = "findByChangedPrincipalName" + UUID.randomUUID(); + RedisSession session = this.repository.createSession().block(); + session.setAttribute(INDEX_NAME, principalName); + + this.repository.save(session).block(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + String changeSessionId = findById.changeSessionId(); + findById.setAttribute(INDEX_NAME, principalNameChanged); + this.repository.save(findById).block(); + + Map findByPrincipalName = this.repository + .findByIndexNameAndIndexValue(INDEX_NAME, principalName) + .block(); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged).block(); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(changeSessionId); + } + + @Test + void changeSessionIdWhenOnlyChangeId() { + String attrName = "changeSessionId"; + String attrValue = "changeSessionId-value"; + RedisSession session = this.repository.createSession().block(); + session.setAttribute(attrName, attrValue); + + this.repository.save(session).block(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + + assertThat(findById.getAttribute(attrName)).isEqualTo(attrValue); + + String originalFindById = findById.getId(); + String changeSessionId = findById.changeSessionId(); + + this.repository.save(findById).block(); + + assertThat(this.repository.findById(originalFindById).block()).isNull(); + + RedisSession findByChangeSessionId = this.repository.findById(changeSessionId).block(); + + assertThat(findByChangeSessionId.getAttribute(attrName)).isEqualTo(attrValue); + } + + @Test + void changeSessionIdWhenChangeTwice() { + RedisSession session = this.repository.createSession().block(); + + this.repository.save(session).block(); + + String originalId = session.getId(); + String changeId1 = session.changeSessionId(); + String changeId2 = session.changeSessionId(); + + this.repository.save(session).block(); + + assertThat(this.repository.findById(originalId).block()).isNull(); + assertThat(this.repository.findById(changeId1).block()).isNull(); + assertThat(this.repository.findById(changeId2).block()).isNotNull(); + } + + @Test + void changeSessionIdWhenSetAttributeOnChangedSession() { + String attrName = "changeSessionId"; + String attrValue = "changeSessionId-value"; + + RedisSession session = this.repository.createSession().block(); + + this.repository.save(session).block(); + + RedisSession findById = this.repository.findById(session.getId()).block(); + + findById.setAttribute(attrName, attrValue); + + String originalFindById = findById.getId(); + String changeSessionId = findById.changeSessionId(); + + this.repository.save(findById).block(); + + assertThat(this.repository.findById(originalFindById).block()).isNull(); + + RedisSession findByChangeSessionId = this.repository.findById(changeSessionId).block(); + + assertThat(findByChangeSessionId.getAttribute(attrName)).isEqualTo(attrValue); + } + + @Test + void changeSessionIdWhenHasNotSaved() { + RedisSession session = this.repository.createSession().block(); + String originalId = session.getId(); + session.changeSessionId(); + + this.repository.save(session).block(); + + assertThat(this.repository.findById(session.getId()).block()).isNotNull(); + assertThat(this.repository.findById(originalId).block()).isNull(); + } + + // gh-962 + @Test + void changeSessionIdSaveTwice() { + RedisSession toSave = this.repository.createSession().block(); + String originalId = toSave.getId(); + toSave.changeSessionId(); + + this.repository.save(toSave).block(); + this.repository.save(toSave).block(); + + assertThat(this.repository.findById(toSave.getId()).block()).isNotNull(); + assertThat(this.repository.findById(originalId).block()).isNull(); + } + + // gh-1137 + @Test + void changeSessionIdWhenSessionIsDeleted() { + RedisSession toSave = this.repository.createSession().block(); + String sessionId = toSave.getId(); + this.repository.save(toSave).block(); + + this.repository.deleteById(sessionId).block(); + + toSave.changeSessionId(); + this.repository.save(toSave).block(); + + assertThat(this.repository.findById(toSave.getId()).block()).isNull(); + assertThat(this.repository.findById(sessionId).block()).isNull(); + } + + @Test // gh-1270 + void changeSessionIdSaveConcurrently() { + RedisSession toSave = this.repository.createSession().block(); + String originalId = toSave.getId(); + this.repository.save(toSave).block(); + + RedisSession copy1 = this.repository.findById(originalId).block(); + RedisSession copy2 = this.repository.findById(originalId).block(); + + copy1.changeSessionId(); + this.repository.save(copy1).block(); + copy2.changeSessionId(); + this.repository.save(copy2).block(); + + assertThat(this.repository.findById(originalId).block()).isNull(); + assertThat(this.repository.findById(copy1.getId()).block()).isNotNull(); + assertThat(this.repository.findById(copy2.getId()).block()).isNull(); + } + + // gh-1743 + @Test + void saveChangeSessionIdWhenFailedRenameOperationExceptionContainsMoreDetailsThenIgnoreError() { + RedisSession toSave = this.repository.createSession().block(); + String sessionId = toSave.getId(); + + this.repository.save(toSave).block(); + RedisSession session = this.repository.findById(sessionId).block(); + this.repository.deleteById(sessionId).block(); + session.changeSessionId(); + + assertThatNoException().isThrownBy(() -> this.repository.save(session).block()); + } + + private String getSecurityName() { + return this.context.getAuthentication().getName(); + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisIndexedWebSession + @Import(AbstractRedisITests.BaseConfig.class) + static class Config { + + @Bean + SessionEventRegistry sessionEventRegistry() { + return new SessionEventRegistry(); + } + + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepository.java new file mode 100644 index 00000000..4d4c4c23 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisIndexedSessionRepository.java @@ -0,0 +1,788 @@ +/* + * Copyright 2014-2023 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.data.redis; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.data.redis.connection.ReactiveSubscription; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.session.IndexResolver; +import org.springframework.session.MapSession; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.SaveMode; +import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionDestroyedEvent; +import org.springframework.session.events.SessionExpiredEvent; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A {@link ReactiveSessionRepository} that is implemented using Spring Data's + * {@link ReactiveRedisOperations}. + * + *

Storage Details

The sections below outline how Redis is updated for each + * operation. An example of creating a new session can be found below. The subsequent + * sections describe the details. + * + *
+ * HMSET spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 creationTime 1702400400000 maxInactiveInterval 1800 lastAccessedTime 1702400400000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
+ * EXPIRE spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 2100
+ * APPEND spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 ""
+ * EXPIRE spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 1800
+ * ZADD spring:session:sessions:expirations "1.702402961162E12" "648377f7-c76f-4f45-b847-c0268bb48381"
+ * SADD spring:session:sessions:index:PRINCIPAL_NAME_INDEX_NAME:user "648377f7-c76f-4f45-b847-c0268bb48381"
+ * SADD spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381:idx "spring:session:sessions:index:PRINCIPAL_NAME_INDEX_NAME:user"
+ * 
+ * + *

Saving a Session

+ * + *

+ * Each session is stored in Redis as a + * Hash. Each session is set and + * updated using the HMSET command. An + * example of how each session is stored can be seen below. + *

+ * + *
+ * HMSET spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 creationTime 1702400400000 maxInactiveInterval 1800 lastAccessedTime 1702400400000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
+ * 
+ * + *

+ * In this example, the session following statements are true about the session: + *

+ *
    + *
  • The session id is 648377f7-c76f-4f45-b847-c0268bb48381
  • + *
  • The session was created at 1702400400000 in milliseconds since midnight of 1/1/1970 + * GMT.
  • + *
  • The session expires in 1800 seconds (30 minutes).
  • + *
  • The session was last accessed at 1702400400000 in milliseconds since midnight of + * 1/1/1970 GMT.
  • + *
  • The session has two attributes. The first is "attrName" with the value of + * "someAttrValue". The second session attribute is named "attrName2" with the value of + * "someAttrValue2".
  • + *
+ * + *

Optimized Writes

+ * + *

+ * The {@link ReactiveRedisIndexedSessionRepository.RedisSession} keeps track of the + * properties that have changed and only updates those. This means if an attribute is + * written once and read many times we only need to write that attribute once. For + * example, assume the session attribute "attrName2" from earlier was updated. The + * following would be executed upon saving: + *

+ * + *
+ * HMSET spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 sessionAttr:attrName2 newValue
+ * 
+ * + *

SessionCreatedEvent

+ * + *

+ * When a session is created an event is sent to Redis with the channel of + * "spring:session:event:0:created:648377f7-c76f-4f45-b847-c0268bb48381" such that + * "648377f7-c76f-4f45-b847-c0268bb48381" is the session id. The body of the event will be + * the session that was created. + *

+ * + *

SessionDeletedEvent and SessionExpiredEvent

If you configured you Redis server + * to send keyspace events when keys are expired or deleted, either via + * {@link org.springframework.session.data.redis.config.annotation.ConfigureNotifyKeyspaceEventsReactiveAction} + * or via external configuration, then deleted and expired sessions will be published as + * {@link SessionDeletedEvent} and {@link SessionExpiredEvent} respectively. + * + *

Expiration

+ * + *

+ * An expiration is associated to each session using the + * EXPIRE command based upon the + * {@link ReactiveRedisIndexedSessionRepository.RedisSession#getMaxInactiveInterval()} . + * For example: + *

+ * + *
+ * EXPIRE spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 2100
+ * 
+ * + *

+ * You will note that the expiration that is set is 5 minutes after the session actually + * expires. This is necessary so that the value of the session can be accessed when the + * session expires. An expiration is set on the session itself five minutes after it + * actually expires to ensure it is cleaned up, but only after we perform any necessary + * processing. + *

+ * + *

+ * NOTE: The {@link #findById(String)} method ensures that no expired sessions will + * be returned. This means there is no need to check the expiration before using a + * session. + *

+ * + *

+ * Spring Session relies on the expired and delete + * keyspace + * notifications from Redis to fire a SessionDestroyedEvent. It is the + * SessionDestroyedEvent that ensures resources associated with the Session are cleaned + * up. For example, when using Spring Session's WebSocket support the Redis expired or + * delete event is what triggers any WebSocket connections associated with the session to + * be closed. + *

+ * + *

+ * Expiration is not tracked directly on the session key itself since this would mean the + * session data would no longer be available. Instead a special session expires key is + * used. In our example the expires key is: + *

+ * + *
+ * APPEND spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 ""
+ * EXPIRE spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 1800
+ * 
+ * + *

+ * When a session key is deleted or expires, the keyspace notification triggers a lookup + * of the actual session and a {@link SessionDestroyedEvent} is fired. + *

+ * + *

+ * One problem with relying on Redis expiration exclusively is that Redis makes no + * guarantee of when the expired event will be fired if the key has not been accessed. For + * additional details see How Redis expires + * keys section in the Redis Expire documentation. + *

+ * + *

+ * To circumvent the fact that expired events are not guaranteed to happen we can ensure + * that each key is accessed when it is expected to expire. This means that if the TTL is + * expired on the key, Redis will remove the key and fire the expired event when we try to + * access the key. + *

+ * + *

+ * For this reason, each session expiration is also tracked by storing the session id in a + * sorted set ranked by its expiration time. This allows a background task to access the + * potentially expired sessions to ensure that Redis expired events are fired in a more + * deterministic fashion. For example: + *

+ * + *
+ * ZADD spring:session:sessions:expirations "1.702402961162E12" "648377f7-c76f-4f45-b847-c0268bb48381"
+ * 
+ * + *

+ * NOTE: We do not explicitly delete the keys since in some instances there may be + * a race condition that incorrectly identifies a key as expired when it is not. Short of + * using distributed locks (which would kill our performance) there is no way to ensure + * the consistency of the expiration mapping. By simply accessing the key, we ensure that + * the key is only removed if the TTL on that key is expired. + *

+ * + *

Secondary Indexes

By default, Spring Session will also index the sessions by + * identifying if the session contains any attribute that can be mapped to a principal + * using an {@link org.springframework.session.PrincipalNameIndexResolver}. All resolved + * indexes for a session are stored in a Redis Set, for example:
+ * SADD spring:session:sessions:index:PRINCIPAL_NAME_INDEX_NAME:user "648377f7-c76f-4f45-b847-c0268bb48381"
+ * SADD spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381:idx "spring:session:sessions:index:PRINCIPAL_NAME_INDEX_NAME:user"
+ * 
+ * + * Therefore, you can check all indexes for a given session by getting the members of the + * {@code "spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381:idx"} Redis set. + * + * @author Marcus da Coregio + * @since 3.3 + */ +public class ReactiveRedisIndexedSessionRepository + implements ReactiveSessionRepository, + ReactiveFindByIndexNameSessionRepository, DisposableBean, + InitializingBean { + + private static final Log logger = LogFactory.getLog(ReactiveRedisIndexedSessionRepository.class); + + /** + * The default namespace for each key and channel in Redis used by Spring Session. + */ + public static final String DEFAULT_NAMESPACE = "spring:session"; + + /** + * The default Redis database used by Spring Session. + */ + public static final int DEFAULT_DATABASE = 0; + + private final ReactiveRedisOperations sessionRedisOperations; + + private final ReactiveRedisTemplate keyEventsOperations; + + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + + private BiFunction, Mono> redisSessionMapper = new RedisSessionMapperAdapter(); + + private Duration defaultMaxInactiveInterval = Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); + + private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + + private ApplicationEventPublisher eventPublisher = (event) -> { + }; + + private String sessionCreatedChannelPrefix; + + private String sessionDeletedChannel; + + private String sessionExpiredChannel; + + private String expiredKeyPrefix; + + private final List subscriptions = new ArrayList<>(); + + /** + * The namespace for every key used by Spring Session in Redis. + */ + private String namespace = DEFAULT_NAMESPACE + ":"; + + private int database = DEFAULT_DATABASE; + + private ReactiveRedisSessionIndexer indexer; + + private SortedSetReactiveRedisSessionExpirationStore expirationStore; + + private Duration cleanupInterval = Duration.ofSeconds(60); + + private Clock clock = Clock.systemUTC(); + + /** + * Creates a new instance with the provided {@link ReactiveRedisOperations}. + * @param sessionRedisOperations the {@link ReactiveRedisOperations} to use for + * managing the sessions. Cannot be null. + * @param keyEventsOperations the {@link ReactiveRedisTemplate} to use to subscribe to + * keyspace events. Cannot be null. + */ + public ReactiveRedisIndexedSessionRepository(ReactiveRedisOperations sessionRedisOperations, + ReactiveRedisTemplate keyEventsOperations) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null"); + Assert.notNull(keyEventsOperations, "keyEventsOperations cannot be null"); + this.sessionRedisOperations = sessionRedisOperations; + this.keyEventsOperations = keyEventsOperations; + this.indexer = new ReactiveRedisSessionIndexer(sessionRedisOperations, this.namespace); + this.expirationStore = new SortedSetReactiveRedisSessionExpirationStore(sessionRedisOperations, this.namespace); + configureSessionChannels(); + } + + @Override + public void afterPropertiesSet() throws Exception { + subscribeToRedisEvents(); + setupCleanupTask(); + } + + private void setupCleanupTask() { + if (!this.cleanupInterval.isZero()) { + Disposable cleanupExpiredSessionsTask = Flux.interval(this.cleanupInterval, this.cleanupInterval) + .onBackpressureDrop((count) -> logger + .debug("Skipping clean-up expired sessions because the previous one is still running.")) + .concatMap((count) -> cleanUpExpiredSessions()) + .subscribe(); + this.subscriptions.add(cleanupExpiredSessionsTask); + } + } + + private Flux cleanUpExpiredSessions() { + return this.expirationStore.retrieveExpiredSessions(this.clock.instant()).flatMap(this::touch); + } + + private Mono touch(String sessionId) { + return this.sessionRedisOperations.hasKey(getExpiredKey(sessionId)).then(); + } + + @Override + public void destroy() { + for (Disposable subscription : this.subscriptions) { + subscription.dispose(); + } + this.subscriptions.clear(); + } + + @Override + public Mono> findByIndexNameAndIndexValue(String indexName, String indexValue) { + return this.indexer.getSessionIds(indexName, indexValue) + .flatMap(this::findById) + .collectMap(RedisSession::getId); + } + + @Override + public Mono createSession() { + return Mono.fromSupplier(() -> this.sessionIdGenerator.generate()) + .subscribeOn(Schedulers.boundedElastic()) + .publishOn(Schedulers.parallel()) + .map(MapSession::new) + .doOnNext((session) -> session.setMaxInactiveInterval(this.defaultMaxInactiveInterval)) + .map((session) -> new RedisSession(session, true)); + } + + @Override + public Mono save(RedisSession session) { + // @formatter:off + return session.save() + .then(Mono.defer(() -> this.indexer.update(session))) + .then(Mono.defer(() -> this.expirationStore.add(session.getId(), session.getLastAccessedTime().plus(session.getMaxInactiveInterval())))); + // @formatter:on + } + + @Override + public Mono findById(String id) { + return getSession(id, false); + } + + private Mono getSession(String sessionId, boolean allowExpired) { + // @formatter:off + String sessionKey = getSessionKey(sessionId); + return this.sessionRedisOperations.opsForHash().entries(sessionKey) + .collectMap((entry) -> entry.getKey().toString(), Map.Entry::getValue) + .filter((map) -> !map.isEmpty()) + .flatMap((map) -> this.redisSessionMapper.apply(sessionId, map)) + .filter((session) -> allowExpired || !session.isExpired()) + .map((session) -> new RedisSession(session, false)); + // @formatter:on + } + + @Override + public Mono deleteById(String id) { + // @formatter:off + return getSession(id, true) + .flatMap((session) -> this.sessionRedisOperations.delete(getExpiredKey(session.getId())) + .thenReturn(session)) + .flatMap((session) -> this.sessionRedisOperations.delete(getSessionKey(session.getId())).thenReturn(session)) + .flatMap((session) -> this.indexer.delete(session.getId()).thenReturn(session)) + .flatMap((session) -> this.expirationStore.remove(session.getId())); + // @formatter:on + } + + /** + * Subscribes to {@code __keyevent@0__:expired} and {@code __keyevent@0__:del} Redis + * Keyspaces events and to {@code spring:session:event:0:created:*} Redis Channel + * event in order to clean up the sessions and publish the related Spring Session + * events. + */ + private void subscribeToRedisEvents() { + Disposable sessionCreatedSubscription = this.sessionRedisOperations + .listenToPattern(getSessionCreatedChannelPrefix() + "*") + .flatMap(this::onSessionCreatedChannelMessage) + .subscribe(); + Disposable sessionDestroyedSubscription = this.keyEventsOperations + .listenToChannel(getSessionDeletedChannel(), getSessionExpiredChannel()) + .flatMap(this::onKeyDestroyedMessage) + .subscribe(); + this.subscriptions.addAll(Arrays.asList(sessionCreatedSubscription, sessionDestroyedSubscription)); + } + + @SuppressWarnings("unchecked") + private Mono onSessionCreatedChannelMessage(ReactiveSubscription.Message message) { + return Mono.just(message.getChannel()) + .filter((channel) -> channel.startsWith(getSessionCreatedChannelPrefix())) + .map((channel) -> { + int sessionIdBeginIndex = channel.lastIndexOf(":") + 1; + return channel.substring(sessionIdBeginIndex); + }) + .flatMap((sessionId) -> { + Map entries = (Map) message.getMessage(); + return this.redisSessionMapper.apply(sessionId, entries); + }) + .map((loaded) -> { + RedisSession session = new RedisSession(loaded, false); + return new SessionCreatedEvent(this, session); + }) + .doOnNext(this::publishEvent) + .then(); + } + + private Mono onKeyDestroyedMessage(ReactiveSubscription.Message message) { + return Mono.just(message.getMessage()).filter((key) -> key.startsWith(getExpiredKeyPrefix())).map((key) -> { + int sessionIdBeginIndex = key.lastIndexOf(":") + 1; + return key.substring(sessionIdBeginIndex); + }) + .flatMap((sessionId) -> getSession(sessionId, true)) + .flatMap((session) -> this.deleteById(session.getId()).thenReturn(session)) + .map((session) -> { + if (message.getChannel().equals(this.sessionDeletedChannel)) { + return new SessionDeletedEvent(this, session); + } + return new SessionExpiredEvent(this, session); + }) + .doOnNext(this::publishEvent) + .then(); + } + + private void publishEvent(Object event) { + this.eventPublisher.publishEvent(event); + } + + /** + * Sets the Redis database index used by Spring Session. + * @param database the database index to use + */ + public void setDatabase(int database) { + this.database = database; + configureSessionChannels(); + } + + /** + * Sets the namespace for keys used by Spring Session. Defaults to 'spring:session:'. + * @param namespace the namespace to set + */ + public void setRedisKeyNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be null or empty"); + this.namespace = namespace.endsWith(":") ? namespace : namespace.trim() + ":"; + this.indexer.setNamespace(this.namespace); + this.expirationStore.setNamespace(this.namespace); + configureSessionChannels(); + } + + /** + * Sets the interval that the clean-up of expired sessions task should run. Defaults + * to 60 seconds. Use {@link Duration#ZERO} to disable it. + * @param cleanupInterval the interval to use + */ + public void setCleanupInterval(Duration cleanupInterval) { + Assert.notNull(cleanupInterval, "cleanupInterval cannot be null"); + this.cleanupInterval = cleanupInterval; + } + + /** + * Disables the clean-up task. This is just a shortcut to invoke + * {@link #setCleanupInterval(Duration)} passing {@link Duration#ZERO} + */ + public void disableCleanupTask() { + setCleanupInterval(Duration.ZERO); + } + + /** + * Sets the {@link Clock} to use. Defaults to {@link Clock#systemUTC()}. + * @param clock the clock to use + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + + public void setDefaultMaxInactiveInterval(Duration defaultMaxInactiveInterval) { + Assert.notNull(defaultMaxInactiveInterval, "defaultMaxInactiveInterval must not be null"); + this.defaultMaxInactiveInterval = defaultMaxInactiveInterval; + } + + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + Assert.notNull(sessionIdGenerator, "sessionIdGenerator cannot be null"); + this.sessionIdGenerator = sessionIdGenerator; + } + + public void setRedisSessionMapper(BiFunction, Mono> redisSessionMapper) { + Assert.notNull(redisSessionMapper, "redisSessionMapper cannot be null"); + this.redisSessionMapper = redisSessionMapper; + } + + public void setSaveMode(SaveMode saveMode) { + Assert.notNull(saveMode, "saveMode cannot be null"); + this.saveMode = saveMode; + } + + public ReactiveRedisOperations getSessionRedisOperations() { + return this.sessionRedisOperations; + } + + public void setEventPublisher(ApplicationEventPublisher eventPublisher) { + Assert.notNull(eventPublisher, "eventPublisher cannot be null"); + this.eventPublisher = eventPublisher; + } + + public void setIndexResolver(IndexResolver indexResolver) { + Assert.notNull(indexResolver, "indexResolver cannot be null"); + this.indexer.setIndexResolver(indexResolver); + } + + private static String getAttributeNameWithPrefix(String attributeName) { + return RedisSessionMapper.ATTRIBUTE_PREFIX + attributeName; + } + + private String getSessionKey(String sessionId) { + return this.namespace + "sessions:" + sessionId; + } + + private String getExpiredKey(String sessionId) { + return getExpiredKeyPrefix() + sessionId; + } + + private String getExpiredKeyPrefix() { + return this.expiredKeyPrefix; + } + + private void configureSessionChannels() { + this.sessionCreatedChannelPrefix = this.namespace + "event:" + this.database + ":created:"; + this.sessionDeletedChannel = "__keyevent@" + this.database + "__:del"; + this.sessionExpiredChannel = "__keyevent@" + this.database + "__:expired"; + this.expiredKeyPrefix = this.namespace + "sessions:expires:"; + } + + public String getSessionCreatedChannel(String sessionId) { + return getSessionCreatedChannelPrefix() + sessionId; + } + + public String getSessionCreatedChannelPrefix() { + return this.sessionCreatedChannelPrefix; + } + + public String getSessionDeletedChannel() { + return this.sessionDeletedChannel; + } + + public String getSessionExpiredChannel() { + return this.sessionExpiredChannel; + } + + public final class RedisSession implements Session { + + private final MapSession cached; + + private Map delta = new HashMap<>(); + + private boolean isNew; + + private String originalSessionId; + + private Map indexes = new HashMap<>(); + + public RedisSession(MapSession cached, boolean isNew) { + this.cached = cached; + this.isNew = isNew; + this.originalSessionId = cached.getId(); + if (this.isNew) { + this.delta.put(RedisSessionMapper.CREATION_TIME_KEY, cached.getCreationTime().toEpochMilli()); + this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) cached.getMaxInactiveInterval().getSeconds()); + this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, cached.getLastAccessedTime().toEpochMilli()); + } + if (this.isNew || (ReactiveRedisIndexedSessionRepository.this.saveMode == SaveMode.ALWAYS)) { + getAttributeNames().forEach((attributeName) -> this.delta.put(getAttributeNameWithPrefix(attributeName), + cached.getAttribute(attributeName))); + } + } + + @Override + public String getId() { + return this.cached.getId(); + } + + @Override + public String changeSessionId() { + String newSessionId = ReactiveRedisIndexedSessionRepository.this.sessionIdGenerator.generate(); + this.cached.setId(newSessionId); + return newSessionId; + } + + @Override + public T getAttribute(String attributeName) { + T attributeValue = this.cached.getAttribute(attributeName); + if (attributeValue != null + && ReactiveRedisIndexedSessionRepository.this.saveMode.equals(SaveMode.ON_GET_ATTRIBUTE)) { + this.delta.put(getAttributeNameWithPrefix(attributeName), attributeValue); + } + return attributeValue; + } + + @Override + public Set getAttributeNames() { + return this.cached.getAttributeNames(); + } + + @Override + public void setAttribute(String attributeName, Object attributeValue) { + this.cached.setAttribute(attributeName, attributeValue); + this.delta.put(getAttributeNameWithPrefix(attributeName), attributeValue); + } + + @Override + public void removeAttribute(String attributeName) { + this.cached.removeAttribute(attributeName); + this.delta.put(getAttributeNameWithPrefix(attributeName), null); + } + + @Override + public Instant getCreationTime() { + return this.cached.getCreationTime(); + } + + @Override + public void setLastAccessedTime(Instant lastAccessedTime) { + this.cached.setLastAccessedTime(lastAccessedTime); + this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, getLastAccessedTime().toEpochMilli()); + } + + @Override + public Instant getLastAccessedTime() { + return this.cached.getLastAccessedTime(); + } + + @Override + public void setMaxInactiveInterval(Duration interval) { + this.cached.setMaxInactiveInterval(interval); + this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) getMaxInactiveInterval().getSeconds()); + } + + @Override + public Duration getMaxInactiveInterval() { + return this.cached.getMaxInactiveInterval(); + } + + @Override + public boolean isExpired() { + return this.cached.isExpired(); + } + + public Map getIndexes() { + return Collections.unmodifiableMap(this.indexes); + } + + private boolean hasChangedSessionId() { + return !getId().equals(this.originalSessionId); + } + + private Mono save() { + return Mono + .defer(() -> saveChangeSessionId().then(saveDelta()).doOnSuccess((unused) -> this.isNew = false)); + } + + private Mono saveDelta() { + if (this.delta.isEmpty()) { + return Mono.empty(); + } + + String sessionKey = getSessionKey(getId()); + Mono update = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.opsForHash() + .putAll(sessionKey, new HashMap<>(this.delta)); + + String expiredKey = getExpiredKey(getId()); + Mono setTtl; + Mono updateExpireKey = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations + .opsForValue() + .append(expiredKey, "") + .hasElement(); + if (getMaxInactiveInterval().getSeconds() >= 0) { + Duration fiveMinutesFromActualExpiration = getMaxInactiveInterval().plus(Duration.ofMinutes(5)); + setTtl = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.expire(sessionKey, + fiveMinutesFromActualExpiration); + updateExpireKey = updateExpireKey + .flatMap((length) -> ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations + .expire(expiredKey, getMaxInactiveInterval())); + } + else { + setTtl = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.persist(sessionKey); + updateExpireKey = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.delete(expiredKey) + .hasElement(); + } + + Mono publishCreated = Mono.empty(); + if (this.isNew) { + String sessionCreatedChannelKey = getSessionCreatedChannel(getId()); + publishCreated = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations + .convertAndSend(sessionCreatedChannelKey, this.delta) + .then(); + } + + return update.flatMap((updated) -> setTtl) + .then(updateExpireKey) + .then(publishCreated) + .then(Mono.fromRunnable(() -> this.delta = new HashMap<>(this.delta.size()))) + .then(); + } + + private Mono saveChangeSessionId() { + if (!hasChangedSessionId()) { + return Mono.empty(); + } + + String sessionId = getId(); + + Mono replaceSessionId = Mono.fromRunnable(() -> this.originalSessionId = sessionId).then(); + + if (this.isNew) { + return Mono.from(replaceSessionId); + } + else { + String originalSessionKey = getSessionKey(this.originalSessionId); + String sessionKey = getSessionKey(sessionId); + String originalExpiredKey = getExpiredKey(this.originalSessionId); + String expiredKey = getExpiredKey(sessionId); + + return renameKey(originalSessionKey, sessionKey) + .then(Mono.defer(() -> renameKey(originalExpiredKey, expiredKey))) + .then(Mono.defer(this::replaceSessionIdOnIndexes)) + .then(Mono.defer(() -> replaceSessionId)); + } + + } + + private Mono replaceSessionIdOnIndexes() { + return ReactiveRedisIndexedSessionRepository.this.indexer.delete(this.originalSessionId) + .then(ReactiveRedisIndexedSessionRepository.this.indexer.update(this)); + } + + private Mono renameKey(String oldKey, String newKey) { + return ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.rename(oldKey, newKey) + .onErrorResume((ex) -> { + String message = NestedExceptionUtils.getMostSpecificCause(ex).getMessage(); + return StringUtils.startsWithIgnoreCase(message, "ERR no such key"); + }, (ex) -> Mono.empty()) + .then(); + } + + } + + private static final class RedisSessionMapperAdapter + implements BiFunction, Mono> { + + private final RedisSessionMapper mapper = new RedisSessionMapper(); + + @Override + public Mono apply(String sessionId, Map map) { + return Mono.fromSupplier(() -> this.mapper.apply(sessionId, map)); + } + + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexer.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexer.java new file mode 100644 index 00000000..9d0496aa --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexer.java @@ -0,0 +1,152 @@ +/* + * Copyright 2014-2023 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.data.redis; + +import java.util.HashMap; +import java.util.Map; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuples; + +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.session.DelegatingIndexResolver; +import org.springframework.session.IndexResolver; +import org.springframework.session.PrincipalNameIndexResolver; +import org.springframework.session.Session; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Uses an {@link IndexResolver} to keep track of the indexes for a + * {@link ReactiveRedisIndexedSessionRepository.RedisSession}. Only updates indexes that + * have changed. + * + * @author Marcus da Coregio + */ +final class ReactiveRedisSessionIndexer { + + private final ReactiveRedisOperations sessionRedisOperations; + + private String namespace; + + private IndexResolver indexResolver = new DelegatingIndexResolver<>( + new PrincipalNameIndexResolver<>(ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME)); + + private String indexKeyPrefix; + + ReactiveRedisSessionIndexer(ReactiveRedisOperations sessionRedisOperations, String namespace) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null"); + Assert.hasText(namespace, "namespace cannot be empty"); + this.sessionRedisOperations = sessionRedisOperations; + this.namespace = namespace; + updateIndexKeyPrefix(); + } + + Mono update(RedisSession redisSession) { + return getIndexes(redisSession.getId()).map((originalIndexes) -> { + Map indexes = this.indexResolver.resolveIndexesFor(redisSession); + Map indexToDelete = new HashMap<>(); + Map indexToAdd = new HashMap<>(); + for (Map.Entry entry : indexes.entrySet()) { + if (!originalIndexes.containsKey(entry.getKey())) { + indexToAdd.put(entry.getKey(), entry.getValue()); + continue; + } + if (!originalIndexes.get(entry.getKey()).equals(entry.getValue())) { + indexToDelete.put(entry.getKey(), originalIndexes.get(entry.getKey())); + indexToAdd.put(entry.getKey(), entry.getValue()); + } + } + if (CollectionUtils.isEmpty(indexes) && !CollectionUtils.isEmpty(originalIndexes)) { + indexToDelete.putAll(originalIndexes); + } + return Tuples.of(indexToDelete, indexToAdd); + }).flatMap((indexes) -> updateIndexes(indexes.getT1(), indexes.getT2(), redisSession.getId())); + } + + private Mono updateIndexes(Map indexToDelete, Map indexToAdd, + String sessionId) { + // @formatter:off + return Flux.fromIterable(indexToDelete.entrySet()) + .flatMap((entry) -> { + String indexKey = getIndexKey(entry.getKey(), entry.getValue()); + return removeSessionFromIndex(indexKey, sessionId).thenReturn(indexKey); + }) + .flatMap((indexKey) -> this.sessionRedisOperations.opsForSet().remove(getSessionIndexesKey(sessionId), indexKey)) + .thenMany(Flux.fromIterable(indexToAdd.entrySet())) + .flatMap((entry) -> { + String indexKey = getIndexKey(entry.getKey(), entry.getValue()); + return this.sessionRedisOperations.opsForSet().add(indexKey, sessionId).thenReturn(indexKey); + }) + .flatMap((indexKey) -> this.sessionRedisOperations.opsForSet().add(getSessionIndexesKey(sessionId), indexKey)) + .then(); + // @formatter:on + } + + Mono delete(String sessionId) { + String sessionIndexesKey = getSessionIndexesKey(sessionId); + return this.sessionRedisOperations.opsForSet() + .members(sessionIndexesKey) + .flatMap((indexKey) -> removeSessionFromIndex((String) indexKey, sessionId)) + .then(this.sessionRedisOperations.delete(sessionIndexesKey)) + .then(); + } + + private Mono removeSessionFromIndex(String indexKey, String sessionId) { + return this.sessionRedisOperations.opsForSet().remove(indexKey, sessionId).then(); + } + + Mono> getIndexes(String sessionId) { + String sessionIndexesKey = getSessionIndexesKey(sessionId); + return this.sessionRedisOperations.opsForSet() + .members(sessionIndexesKey) + .cast(String.class) + .collectMap((indexKey) -> indexKey.substring(this.indexKeyPrefix.length()).split(":")[0], + (indexKey) -> indexKey.substring(this.indexKeyPrefix.length()).split(":")[1]); + } + + Flux getSessionIds(String indexName, String indexValue) { + String indexKey = getIndexKey(indexName, indexValue); + return this.sessionRedisOperations.opsForSet().members(indexKey).cast(String.class); + } + + private void updateIndexKeyPrefix() { + this.indexKeyPrefix = this.namespace + "sessions:index:"; + } + + private String getSessionIndexesKey(String sessionId) { + return this.namespace + "sessions:" + sessionId + ":idx"; + } + + private String getIndexKey(String indexName, String indexValue) { + return this.indexKeyPrefix + indexName + ":" + indexValue; + } + + void setNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be empty"); + this.namespace = namespace; + updateIndexKeyPrefix(); + } + + void setIndexResolver(IndexResolver indexResolver) { + Assert.notNull(indexResolver, "indexResolver cannot be null"); + this.indexResolver = indexResolver; + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStore.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStore.java new file mode 100644 index 00000000..46487730 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStore.java @@ -0,0 +1,98 @@ +/* + * Copyright 2014-2023 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.data.redis; + +import java.time.Instant; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.Limit; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.util.Assert; + +/** + * Uses a sorted set to store the expiration times for sessions. The score of each entry + * is the expiration time of the session. The value is the session id. + * + * @author Marcus da Coregio + */ +final class SortedSetReactiveRedisSessionExpirationStore { + + private final ReactiveRedisOperations sessionRedisOperations; + + private String namespace; + + private int retrieveCount = 100; + + SortedSetReactiveRedisSessionExpirationStore(ReactiveRedisOperations sessionRedisOperations, + String namespace) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null"); + Assert.hasText(namespace, "namespace cannot be null or empty"); + this.sessionRedisOperations = sessionRedisOperations; + this.namespace = namespace; + } + + /** + * Add the session id associated with the expiration time into the sorted set. + * @param sessionId the session id + * @param expiration the expiration time + * @return a {@link Mono} that completes when the operation completes + */ + Mono add(String sessionId, Instant expiration) { + long expirationInMillis = expiration.toEpochMilli(); + return this.sessionRedisOperations.opsForZSet().add(getExpirationsKey(), sessionId, expirationInMillis).then(); + } + + /** + * Remove the session id from the sorted set. + * @param sessionId the session id + * @return a {@link Mono} that completes when the operation completes + */ + Mono remove(String sessionId) { + return this.sessionRedisOperations.opsForZSet().remove(getExpirationsKey(), sessionId).then(); + } + + /** + * Retrieve the session ids that have the expiration time less than the value passed + * in {@code expiredBefore}. + * @param expiredBefore the expiration time + * @return a {@link Flux} that emits the session ids + */ + Flux retrieveExpiredSessions(Instant expiredBefore) { + Range range = Range.closed(0D, (double) expiredBefore.toEpochMilli()); + Limit limit = Limit.limit().count(this.retrieveCount); + return this.sessionRedisOperations.opsForZSet() + .reverseRangeByScore(getExpirationsKey(), range, limit) + .cast(String.class); + } + + private String getExpirationsKey() { + return this.namespace + "sessions:expirations"; + } + + /** + * Set the namespace for the keys used by this class. + * @param namespace the namespace + */ + void setNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be null or empty"); + this.namespace = namespace; + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/ConfigureReactiveRedisAction.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/ConfigureReactiveRedisAction.java new file mode 100644 index 00000000..1f6e288c --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/ConfigureReactiveRedisAction.java @@ -0,0 +1,39 @@ +/* + * Copyright 2014-2023 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.data.redis.config; + +import reactor.core.publisher.Mono; + +import org.springframework.data.redis.connection.ReactiveRedisConnection; + +/** + * Allows specifying a strategy for configuring and validating Redis using a Reactive + * connection. + * + * @author Marcus da Coregio + * @since 3.3 + */ +public interface ConfigureReactiveRedisAction { + + Mono configure(ReactiveRedisConnection connection); + + /** + * An implementation of {@link ConfigureReactiveRedisAction} that does nothing. + */ + ConfigureReactiveRedisAction NO_OP = (connection) -> Mono.empty(); + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/ConfigureNotifyKeyspaceEventsReactiveAction.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/ConfigureNotifyKeyspaceEventsReactiveAction.java new file mode 100644 index 00000000..08caf708 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/ConfigureNotifyKeyspaceEventsReactiveAction.java @@ -0,0 +1,88 @@ +/* + * Copyright 2014-2023 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.data.redis.config.annotation; + +import java.util.Properties; +import java.util.function.Predicate; + +import reactor.core.publisher.Mono; +import reactor.util.function.Tuples; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.redis.connection.ReactiveRedisConnection; +import org.springframework.session.data.redis.config.ConfigureReactiveRedisAction; + +/** + *

+ * Ensures that Redis Keyspace events for Generic commands and Expired events are enabled. + * For example, it might set the following: + *

+ * + *
+ * config set notify-keyspace-events Egx
+ * 
+ * + *

+ * This strategy will not work if the Redis instance has been properly secured. Instead, + * the Redis instance should be configured externally and a Bean of type + * {@link ConfigureReactiveRedisAction#NO_OP} should be exposed. + *

+ * + * @author Rob Winch + * @author Mark Paluch + * @author Marcus da Coregio + * @since 3.3 + */ +public class ConfigureNotifyKeyspaceEventsReactiveAction implements ConfigureReactiveRedisAction { + + static final String CONFIG_NOTIFY_KEYSPACE_EVENTS = "notify-keyspace-events"; + + @Override + public Mono configure(ReactiveRedisConnection connection) { + return getNotifyOptions(connection).map((notifyOptions) -> { + String customizedNotifyOptions = notifyOptions; + if (!customizedNotifyOptions.contains("E")) { + customizedNotifyOptions += "E"; + } + boolean A = customizedNotifyOptions.contains("A"); + if (!(A || customizedNotifyOptions.contains("g"))) { + customizedNotifyOptions += "g"; + } + if (!(A || customizedNotifyOptions.contains("x"))) { + customizedNotifyOptions += "x"; + } + return Tuples.of(notifyOptions, customizedNotifyOptions); + }) + .filter((optionsTuple) -> !optionsTuple.getT1().equals(optionsTuple.getT2())) + .flatMap((optionsTuple) -> connection.serverCommands() + .setConfig(CONFIG_NOTIFY_KEYSPACE_EVENTS, optionsTuple.getT2())) + .filter("OK"::equals) + .doFinally((unused) -> connection.close()) + .then(); + } + + private Mono getNotifyOptions(ReactiveRedisConnection connection) { + return connection.serverCommands() + .getConfig(CONFIG_NOTIFY_KEYSPACE_EVENTS) + .filter(Predicate.not(Properties::isEmpty)) + .map((config) -> config.getProperty(config.stringPropertyNames().iterator().next())) + .onErrorMap(InvalidDataAccessApiUsageException.class, + (ex) -> new IllegalStateException("Unable to configure Reactive Redis to keyspace notifications", + ex)); + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/AbstractRedisWebSessionConfiguration.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/AbstractRedisWebSessionConfiguration.java new file mode 100644 index 00000000..db9ba65d --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/AbstractRedisWebSessionConfiguration.java @@ -0,0 +1,148 @@ +/* + * Copyright 2014-2023 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.data.redis.config.annotation.web.server; + +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.session.MapSession; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.SaveMode; +import org.springframework.session.Session; +import org.springframework.session.SessionIdGenerator; +import org.springframework.session.UuidSessionIdGenerator; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.config.annotation.web.server.SpringWebSessionConfiguration; +import org.springframework.session.data.redis.ReactiveRedisSessionRepository; +import org.springframework.session.data.redis.config.annotation.SpringSessionRedisConnectionFactory; +import org.springframework.util.Assert; + +@Configuration(proxyBeanMethods = false) +@Import(SpringWebSessionConfiguration.class) +public abstract class AbstractRedisWebSessionConfiguration> { + + private Duration maxInactiveInterval = MapSession.DEFAULT_MAX_INACTIVE_INTERVAL; + + private String redisNamespace = ReactiveRedisSessionRepository.DEFAULT_NAMESPACE; + + private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE; + + private ReactiveRedisConnectionFactory redisConnectionFactory; + + private RedisSerializer defaultRedisSerializer = new JdkSerializationRedisSerializer(); + + private List> sessionRepositoryCustomizers; + + private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance(); + + public abstract T sessionRepository(); + + public void setMaxInactiveInterval(Duration maxInactiveInterval) { + this.maxInactiveInterval = maxInactiveInterval; + } + + public void setRedisNamespace(String namespace) { + Assert.hasText(namespace, "namespace cannot be empty or null"); + this.redisNamespace = namespace; + } + + public void setSaveMode(SaveMode saveMode) { + Assert.notNull(saveMode, "saveMode cannot be null"); + this.saveMode = saveMode; + } + + public Duration getMaxInactiveInterval() { + return this.maxInactiveInterval; + } + + public String getRedisNamespace() { + return this.redisNamespace; + } + + public SaveMode getSaveMode() { + return this.saveMode; + } + + public SessionIdGenerator getSessionIdGenerator() { + return this.sessionIdGenerator; + } + + public RedisSerializer getDefaultRedisSerializer() { + return this.defaultRedisSerializer; + } + + @Autowired + public void setRedisConnectionFactory( + @SpringSessionRedisConnectionFactory ObjectProvider springSessionRedisConnectionFactory, + ObjectProvider redisConnectionFactory) { + ReactiveRedisConnectionFactory redisConnectionFactoryToUse = springSessionRedisConnectionFactory + .getIfAvailable(); + if (redisConnectionFactoryToUse == null) { + redisConnectionFactoryToUse = redisConnectionFactory.getObject(); + } + this.redisConnectionFactory = redisConnectionFactoryToUse; + } + + @Autowired(required = false) + @Qualifier("springSessionDefaultRedisSerializer") + public void setDefaultRedisSerializer(RedisSerializer defaultRedisSerializer) { + this.defaultRedisSerializer = defaultRedisSerializer; + } + + @Autowired(required = false) + public void setSessionRepositoryCustomizer( + ObjectProvider> sessionRepositoryCustomizers) { + this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList()); + } + + protected List> getSessionRepositoryCustomizers() { + return this.sessionRepositoryCustomizers; + } + + protected ReactiveRedisTemplate createReactiveRedisTemplate() { + RedisSerializer keySerializer = RedisSerializer.string(); + RedisSerializer defaultSerializer = (this.defaultRedisSerializer != null) ? this.defaultRedisSerializer + : new JdkSerializationRedisSerializer(); + RedisSerializationContext serializationContext = RedisSerializationContext + .newSerializationContext(defaultSerializer) + .key(keySerializer) + .hashKey(keySerializer) + .build(); + return new ReactiveRedisTemplate<>(this.redisConnectionFactory, serializationContext); + } + + public ReactiveRedisConnectionFactory getRedisConnectionFactory() { + return this.redisConnectionFactory; + } + + @Autowired(required = false) + public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator) { + this.sessionIdGenerator = sessionIdGenerator; + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/EnableRedisIndexedWebSession.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/EnableRedisIndexedWebSession.java new file mode 100644 index 00000000..23d13781 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/EnableRedisIndexedWebSession.java @@ -0,0 +1,89 @@ +/* + * Copyright 2014-2023 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.data.redis.config.annotation.web.server; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.session.MapSession; +import org.springframework.session.SaveMode; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Add this annotation to an {@link org.springframework.context.annotation.Configuration} + * class to expose the {@link WebSessionManager} as a bean named {@code webSessionManager} + * and backed by Reactive Redis. In order to leverage the annotation, a single + * {@link ReactiveRedisConnectionFactory} must be provided. For example: + * + *
+ * @Configuration(proxyBeanMethods = false)
+ * @EnableRedisIndexedWebSession
+ * public class RedisIndexedWebSessionConfig {
+ *
+ *     @Bean
+ *     public LettuceConnectionFactory redisConnectionFactory() {
+ *         return new LettuceConnectionFactory();
+ *     }
+ *
+ * }
+ * 
+ * + * More advanced configurations can extend {@link RedisIndexedWebSessionConfiguration} + * instead. + * + * @author Marcus da Coregio + * @since 3.3 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@Import(RedisIndexedWebSessionConfiguration.class) +public @interface EnableRedisIndexedWebSession { + + /** + * The session timeout in seconds. By default, it is set to 1800 seconds (30 minutes). + * A negative number means permanently valid. + * @return the seconds a session can be inactive before expiring + */ + int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS; + + /** + * Defines a unique namespace for keys. The value is used to isolate sessions by + * changing the prefix from default {@code spring:session:} to + * {@code :}. + *

+ * For example, if you had an application named "Application A" that needed to keep + * the sessions isolated from "Application B" you could set two different values for + * the applications and they could function within the same Redis instance. + * @return the unique namespace for keys + */ + String redisNamespace() default ReactiveRedisIndexedSessionRepository.DEFAULT_NAMESPACE; + + /** + * Save mode for the session. The default is {@link SaveMode#ON_SET_ATTRIBUTE}, which + * only saves changes made to session. + * @return the save mode + */ + SaveMode saveMode() default SaveMode.ON_SET_ATTRIBUTE; + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisIndexedWebSessionConfiguration.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisIndexedWebSessionConfiguration.java new file mode 100644 index 00000000..a706fc46 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/server/RedisIndexedWebSessionConfiguration.java @@ -0,0 +1,205 @@ +/* + * Copyright 2014-2022 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.data.redis.config.annotation.web.server; + +import java.time.Duration; +import java.util.Map; + +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.EmbeddedValueResolverAware; +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.data.redis.connection.ReactiveRedisConnection; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.session.IndexResolver; +import org.springframework.session.Session; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository; +import org.springframework.session.data.redis.config.ConfigureReactiveRedisAction; +import org.springframework.session.data.redis.config.annotation.ConfigureNotifyKeyspaceEventsReactiveAction; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.util.StringValueResolver; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Exposes the {@link WebSessionManager} as a bean named {@code webSessionManager} backed + * by {@link ReactiveRedisIndexedSessionRepository}. In order to use this a single + * {@link ReactiveRedisConnectionFactory} must be exposed as a Bean. + * + * @author Marcus da Coregio + * @since 3.3 + * @see EnableRedisIndexedWebSession + */ +@Configuration(proxyBeanMethods = false) +public class RedisIndexedWebSessionConfiguration + extends AbstractRedisWebSessionConfiguration + implements EmbeddedValueResolverAware, ImportAware { + + private static final boolean lettucePresent; + + private static final boolean jedisPresent; + + private ConfigureReactiveRedisAction configureRedisAction = new ConfigureNotifyKeyspaceEventsReactiveAction(); + + private StringValueResolver embeddedValueResolver; + + private ApplicationEventPublisher eventPublisher; + + private IndexResolver indexResolver; + + static { + ClassLoader classLoader = RedisIndexedWebSessionConfiguration.class.getClassLoader(); + lettucePresent = ClassUtils.isPresent("io.lettuce.core.RedisClient", classLoader); + jedisPresent = ClassUtils.isPresent("redis.clients.jedis.Jedis", classLoader); + } + + @Override + @Bean + public ReactiveRedisIndexedSessionRepository sessionRepository() { + ReactiveRedisTemplate reactiveRedisTemplate = createReactiveRedisTemplate(); + ReactiveRedisIndexedSessionRepository sessionRepository = new ReactiveRedisIndexedSessionRepository( + reactiveRedisTemplate, createReactiveStringRedisTemplate()); + sessionRepository.setDefaultMaxInactiveInterval(getMaxInactiveInterval()); + sessionRepository.setEventPublisher(this.eventPublisher); + if (this.indexResolver != null) { + sessionRepository.setIndexResolver(this.indexResolver); + } + if (StringUtils.hasText(getRedisNamespace())) { + sessionRepository.setRedisKeyNamespace(getRedisNamespace()); + } + int database = resolveDatabase(); + sessionRepository.setDatabase(database); + sessionRepository.setSaveMode(getSaveMode()); + sessionRepository.setSessionIdGenerator(getSessionIdGenerator()); + if (getSessionRepositoryCustomizers() != null) { + getSessionRepositoryCustomizers().forEach((customizer) -> customizer.customize(sessionRepository)); + } + return sessionRepository; + } + + private ReactiveStringRedisTemplate createReactiveStringRedisTemplate() { + return new ReactiveStringRedisTemplate(getRedisConnectionFactory()); + } + + @Bean + public InitializingBean enableRedisKeyspaceNotificationsInitializer() { + return new EnableRedisKeyspaceNotificationsInitializer(getRedisConnectionFactory(), this.configureRedisAction); + } + + @Autowired + public void setEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + /** + * Sets the action to perform for configuring Redis. + * @param configureRedisAction the configuration to apply to Redis. The default is + * {@link ConfigureNotifyKeyspaceEventsReactiveAction} + */ + @Autowired(required = false) + public void setConfigureRedisAction(ConfigureReactiveRedisAction configureRedisAction) { + this.configureRedisAction = configureRedisAction; + } + + @Autowired(required = false) + public void setIndexResolver(IndexResolver indexResolver) { + this.indexResolver = indexResolver; + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.embeddedValueResolver = resolver; + } + + @Override + public void setImportMetadata(AnnotationMetadata importMetadata) { + Map attributeMap = importMetadata + .getAnnotationAttributes(EnableRedisIndexedWebSession.class.getName()); + AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributeMap); + if (attributes == null) { + return; + } + setMaxInactiveInterval(Duration.ofSeconds(attributes.getNumber("maxInactiveIntervalInSeconds"))); + String redisNamespaceValue = attributes.getString("redisNamespace"); + if (StringUtils.hasText(redisNamespaceValue)) { + setRedisNamespace(this.embeddedValueResolver.resolveStringValue(redisNamespaceValue)); + } + setSaveMode(attributes.getEnum("saveMode")); + } + + private int resolveDatabase() { + if (lettucePresent && getRedisConnectionFactory() instanceof LettuceConnectionFactory lettuce) { + return lettuce.getDatabase(); + } + if (jedisPresent && getRedisConnectionFactory() instanceof JedisConnectionFactory jedis) { + return jedis.getDatabase(); + } + return ReactiveRedisIndexedSessionRepository.DEFAULT_DATABASE; + } + + /** + * Ensures that Redis is configured to send keyspace notifications. This is important + * to ensure that expiration and deletion of sessions trigger SessionDestroyedEvents. + * Without the SessionDestroyedEvent resources may not get cleaned up properly. For + * example, the mapping of the Session to WebSocket connections may not get cleaned + * up. + */ + static class EnableRedisKeyspaceNotificationsInitializer implements InitializingBean { + + private final ReactiveRedisConnectionFactory connectionFactory; + + private final ConfigureReactiveRedisAction configure; + + EnableRedisKeyspaceNotificationsInitializer(ReactiveRedisConnectionFactory connectionFactory, + ConfigureReactiveRedisAction configure) { + this.connectionFactory = connectionFactory; + this.configure = configure; + } + + @Override + public void afterPropertiesSet() { + if (this.configure == ConfigureReactiveRedisAction.NO_OP) { + return; + } + ReactiveRedisConnection connection = this.connectionFactory.getReactiveConnection(); + try { + this.configure.configure(connection).block(); + } + finally { + try { + connection.close(); + } + catch (Exception ex) { + LogFactory.getLog(getClass()).error("Error closing RedisConnection", ex); + } + } + } + + } + +} diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexerTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexerTests.java new file mode 100644 index 00000000..14f70fc9 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/ReactiveRedisSessionIndexerTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2014-2023 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.data.redis; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.session.DelegatingIndexResolver; +import org.springframework.session.IndexResolver; +import org.springframework.session.PrincipalNameIndexResolver; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.SingleIndexResolver; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession; +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.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link ReactiveRedisSessionIndexer}. + * + * @author Marcus da Coregio + */ +class ReactiveRedisSessionIndexerTests { + + ReactiveRedisSessionIndexer indexer; + + ReactiveRedisOperations sessionRedisOperations = mock(Answers.RETURNS_DEEP_STUBS); + + String indexKeyPrefix = "spring:session:sessions:index:"; + + @BeforeEach + void setup() { + this.indexer = new ReactiveRedisSessionIndexer(this.sessionRedisOperations, "spring:session:"); + } + + @Test + void getIndexesWhenIndexKeyExistsThenReturnsIndexNameAndValue() { + List indexKeys = List.of(this.indexKeyPrefix + "principalName:user", + this.indexKeyPrefix + "index_name:index_value"); + given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.fromIterable(indexKeys)); + Map indexes = this.indexer.getIndexes("1234").block(); + assertThat(indexes).hasSize(2) + .containsEntry("principalName", "user") + .containsEntry("index_name", "index_value"); + } + + @Test + void deleteWhenSessionIdHasIndexesThenRemoveSessionIdFromIndexesAndDeleteSessionIndexKey() { + String index1 = this.indexKeyPrefix + "principalName:user"; + String index2 = this.indexKeyPrefix + "index_name:index_value"; + List indexKeys = List.of(index1, index2); + given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.fromIterable(indexKeys)); + given(this.sessionRedisOperations.delete(anyString())).willReturn(Mono.just(1L)); + given(this.sessionRedisOperations.opsForSet().remove(anyString(), anyString())).willReturn(Mono.just(1L)); + this.indexer.delete("1234").block(); + verify(this.sessionRedisOperations).delete("spring:session:sessions:1234:idx"); + verify(this.sessionRedisOperations.opsForSet()).remove(index1, "1234"); + verify(this.sessionRedisOperations.opsForSet()).remove(index2, "1234"); + } + + @Test + void updateWhenSessionHasNoIndexesSavedThenUpdates() { + RedisSession session = mock(); + given(session.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME)) + .willReturn("user"); + given(session.getId()).willReturn("1234"); + given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.empty()); + given(this.sessionRedisOperations.opsForSet().add(anyString(), anyString())).willReturn(Mono.just(1L)); + this.indexer.update(session).block(); + verify(this.sessionRedisOperations.opsForSet()).add(this.indexKeyPrefix + "PRINCIPAL_NAME_INDEX_NAME:user", + "1234"); + } + + @Test + void updateWhenSessionIndexesSavedWithSameValueThenDoesNotUpdate() { + String indexKey = this.indexKeyPrefix + ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + + ":user"; + RedisSession session = mock(); + given(session.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME)) + .willReturn("user"); + given(session.getId()).willReturn("1234"); + given(this.sessionRedisOperations.opsForSet().members(anyString())) + .willReturn(Flux.fromIterable(List.of(indexKey))); + this.indexer.update(session).block(); + verify(this.sessionRedisOperations.opsForSet(), never()).add(anyString(), anyString()); + verify(this.sessionRedisOperations.opsForSet(), never()).remove(anyString(), anyString()); + } + + @Test + void updateWhenSessionIndexesSavedWithDifferentValueThenUpdates() { + String indexKey = this.indexKeyPrefix + ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + + ":user"; + RedisSession session = mock(); + given(session.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME)) + .willReturn("newuser"); + given(session.getId()).willReturn("1234"); + given(this.sessionRedisOperations.opsForSet().members(anyString())) + .willReturn(Flux.fromIterable(List.of(indexKey))); + given(this.sessionRedisOperations.opsForSet().add(anyString(), anyString())).willReturn(Mono.just(1L)); + given(this.sessionRedisOperations.opsForSet().remove(anyString(), anyString())).willReturn(Mono.just(1L)); + this.indexer.update(session).block(); + verify(this.sessionRedisOperations.opsForSet()).add( + this.indexKeyPrefix + ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":newuser", + "1234"); + verify(this.sessionRedisOperations.opsForSet()).remove(indexKey, "1234"); + } + + @Test + void updateWhenMultipleIndexResolvedThenUpdated() { + IndexResolver indexResolver = new DelegatingIndexResolver<>( + new PrincipalNameIndexResolver<>(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME), + new TestIndexResolver<>("test")); + this.indexer.setIndexResolver(indexResolver); + RedisSession session = mock(); + given(session.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME)) + .willReturn("user"); + given(session.getAttribute("test")).willReturn("testvalue"); + given(session.getId()).willReturn("1234"); + given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.empty()); + given(this.sessionRedisOperations.opsForSet().add(anyString(), anyString())).willReturn(Mono.just(1L)); + this.indexer.update(session).block(); + verify(this.sessionRedisOperations.opsForSet()).add(this.indexKeyPrefix + "PRINCIPAL_NAME_INDEX_NAME:user", + "1234"); + verify(this.sessionRedisOperations.opsForSet()).add(this.indexKeyPrefix + "test:testvalue", "1234"); + } + + @Test + void setNamespaceShouldUpdateIndexKeyPrefix() { + String originalPrefix = (String) ReflectionTestUtils.getField(this.indexer, "indexKeyPrefix"); + this.indexer.setNamespace("my:namespace:"); + String updatedPrefix = (String) ReflectionTestUtils.getField(this.indexer, "indexKeyPrefix"); + assertThat(originalPrefix).isEqualTo(this.indexKeyPrefix); + assertThat(updatedPrefix).isEqualTo("my:namespace:sessions:index:"); + } + + @Test + void constructorWhenSessionRedisOperationsNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> new ReactiveRedisSessionIndexer(null, "spring:session:")) + .withMessage("sessionRedisOperations cannot be null"); + } + + @Test + void constructorWhenNamespaceNullThenException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReactiveRedisSessionIndexer(this.sessionRedisOperations, null)) + .withMessage("namespace cannot be empty"); + } + + @Test + void constructorWhenNamespaceEmptyThenException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReactiveRedisSessionIndexer(this.sessionRedisOperations, "")) + .withMessage("namespace cannot be empty"); + } + + static class TestIndexResolver extends SingleIndexResolver { + + protected TestIndexResolver(String indexName) { + super(indexName); + } + + @Override + public String resolveIndexValueFor(S session) { + return session.getAttribute(getIndexName()); + } + + } + +} diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStoreTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStoreTests.java new file mode 100644 index 00000000..58dbc030 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetReactiveRedisSessionExpirationStoreTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2014-2023 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.data.redis; + +import java.time.Instant; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.Limit; +import org.springframework.data.redis.core.ReactiveRedisOperations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@SuppressWarnings("unchecked") +class SortedSetReactiveRedisSessionExpirationStoreTests { + + SortedSetReactiveRedisSessionExpirationStore store; + + ReactiveRedisOperations sessionRedisOperations = mock(Answers.RETURNS_DEEP_STUBS); + + String namespace = "spring:session:"; + + @BeforeEach + void setup() { + this.store = new SortedSetReactiveRedisSessionExpirationStore(this.sessionRedisOperations, this.namespace); + given(this.sessionRedisOperations.opsForZSet().add(anyString(), anyString(), anyDouble())) + .willReturn(Mono.empty()); + given(this.sessionRedisOperations.opsForZSet().remove(anyString(), anyString())).willReturn(Mono.empty()); + given(this.sessionRedisOperations.opsForZSet() + .reverseRangeByScore(anyString(), any(Range.class), any(Limit.class))).willReturn(Flux.empty()); + } + + @Test + void addThenStoresSessionIdRankedByExpireAtEpochMilli() { + String sessionId = "1234"; + Instant expireAt = Instant.ofEpochMilli(1702314490000L); + StepVerifier.create(this.store.add(sessionId, expireAt)).verifyComplete(); + verify(this.sessionRedisOperations.opsForZSet()).add(this.namespace + "sessions:expirations", sessionId, + expireAt.toEpochMilli()); + } + + @Test + void removeThenRemovesSessionIdFromSortedSet() { + String sessionId = "1234"; + StepVerifier.create(this.store.remove(sessionId)).verifyComplete(); + verify(this.sessionRedisOperations.opsForZSet()).remove(this.namespace + "sessions:expirations", sessionId); + } + + @Test + void retrieveExpiredSessionsThenUsesExpectedRangeAndLimit() { + Instant now = Instant.now(); + StepVerifier.create(this.store.retrieveExpiredSessions(now)).verifyComplete(); + ArgumentCaptor> rangeCaptor = ArgumentCaptor.forClass(Range.class); + ArgumentCaptor limitCaptor = ArgumentCaptor.forClass(Limit.class); + verify(this.sessionRedisOperations.opsForZSet()).reverseRangeByScore( + eq(this.namespace + "sessions:expirations"), rangeCaptor.capture(), limitCaptor.capture()); + assertThat(rangeCaptor.getValue().getLowerBound().getValue()).hasValue(0D); + assertThat(rangeCaptor.getValue().getUpperBound().getValue()).hasValue((double) now.toEpochMilli()); + assertThat(limitCaptor.getValue().getCount()).isEqualTo(100); + assertThat(limitCaptor.getValue().getOffset()).isEqualTo(0); + } + +} diff --git a/spring-session-dependencies/spring-session-dependencies.gradle b/spring-session-dependencies/spring-session-dependencies.gradle index 9d861d11..c1ca4884 100644 --- a/spring-session-dependencies/spring-session-dependencies.gradle +++ b/spring-session-dependencies/spring-session-dependencies.gradle @@ -41,6 +41,7 @@ dependencies { api libs.org.mongodb.mongodb.driver.sync api libs.org.mongodb.mongodb.driver.reactivestreams api libs.org.postgresql + api libs.org.awaitility.awaitility } } diff --git a/spring-session-docs/modules/ROOT/nav.adoc b/spring-session-docs/modules/ROOT/nav.adoc index d2e4e76a..115b29ad 100644 --- a/spring-session-docs/modules/ROOT/nav.adoc +++ b/spring-session-docs/modules/ROOT/nav.adoc @@ -24,7 +24,9 @@ *** xref:guides/xml-redis.adoc[Redis] *** xref:guides/xml-jdbc.adoc[JDBC] * xref:configurations.adoc[Configurations] -** xref:configuration/redis.adoc[Redis] +** Redis +*** xref:configuration/redis.adoc[Redis HTTP Session] +*** xref:configuration/reactive-redis-indexed.adoc[Redis Indexed Web Session] ** xref:configuration/common.adoc[Common Configurations] * xref:http-session.adoc[HttpSession Integration] * xref:web-socket.adoc[WebSocket Integration] diff --git a/spring-session-docs/modules/ROOT/pages/configuration/reactive-redis-indexed.adoc b/spring-session-docs/modules/ROOT/pages/configuration/reactive-redis-indexed.adoc new file mode 100644 index 00000000..602cdbdf --- /dev/null +++ b/spring-session-docs/modules/ROOT/pages/configuration/reactive-redis-indexed.adoc @@ -0,0 +1,227 @@ +[[reactive-indexed-redis-configurations]] += Reactive Redis Indexed Configurations + +To start using the Redis Indexed Web Session support, you need to add the following dependency to your project: + +[tabs] +====== +Maven:: ++ +[source,xml] +---- + + org.springframework.session + spring-session-data-redis + +---- +Gradle:: ++ +[source,groovy] +---- +implementation 'org.springframework.session:spring-session-data-redis' +---- +====== + +And add the `@EnableRedisIndexedWebSession` annotation to a configuration class: + +[source,java,role="primary"] +---- +@Configuration +@EnableRedisIndexedWebSession +public class SessionConfig { + // ... +} +---- + +That is it. Your application now has a reactive Redis backed Indexed Web Session support. +Now that you have your application configured, you might want to start customizing things: + +- I want to <>. +- I want to <> for keys used by Spring Session. +- I want to know <>. +- I want to <>. +- I want to <>. +- I want to <>. + +[[serializing-session-using-json]] +== Serializing the Session using JSON + +By default, Spring Session Data Redis uses Java Serialization to serialize the session attributes. +Sometimes it might be problematic, especially when you have multiple applications that use the same Redis instance but have different versions of the same class. +You can provide a `RedisSerializer` bean to customize how the session is serialized into Redis. +Spring Data Redis provides the `GenericJackson2JsonRedisSerializer` that serializes and deserializes objects using Jackson's `ObjectMapper`. + +==== +.Configuring the RedisSerializer +[source,java] +---- +include::{samples-dir}spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java[tags=class] +---- +==== + +The above code snippet is using Spring Security, therefore we are creating a custom `ObjectMapper` that uses Spring Security's Jackson modules. +If you do not need Spring Security Jackson modules, you can inject your application's `ObjectMapper` bean and use it like so: + +==== +[source,java] +---- +@Bean +public RedisSerializer springSessionDefaultRedisSerializer(ObjectMapper objectMapper) { + return new GenericJackson2JsonRedisSerializer(objectMapper); +} +---- +==== + +[NOTE] +==== +The `RedisSerializer` bean name must be `springSessionDefaultRedisSerializer` so it does not conflict with other `RedisSerializer` beans used by Spring Data Redis. +If a different name is provided it won't be picked up by Spring Session. +==== + +[[using-a-different-namespace]] +== Specifying a Different Namespace + +It is not uncommon to have multiple applications that use the same Redis instance or to want to keep the session data separated from other data stored in Redis. +For that reason, Spring Session uses a `namespace` (defaults to `spring:session`) to keep the session data separated if needed. + +You can specify the `namespace` by setting the `redisNamespace` property in the `@EnableRedisIndexedWebSession` annotation: + +==== +.Specifying a different namespace +[source,java,role="primary"] +---- +@Configuration +@EnableRedisIndexedWebSession(redisNamespace = "spring:session:myapplication") +public class SessionConfig { + // ... +} +---- +==== + +[[how-spring-session-cleans-up-expired-sessions]] +== Understanding How Spring Session Cleans Up Expired Sessions + +Spring Session relies on https://redis.io/docs/manual/keyspace-notifications/[Redis Keyspace Events] to clean up expired sessions. +More specifically, it listens to events emitted to the `pass:[__keyevent@*__:expired]` and `pass:[__keyevent@*__:del]` channels and resolve the session id based on the key that was destroyed. + +As an example, let's imagine that we have a session with id `1234` and that the session is set to expire in 30 minutes. +When the expiration time is reached, Redis will emit an event to the `pass:[__keyevent@*__:expired]` channel with the message `spring:session:sessions:expires:1234` which is the key that expired. +Spring Session will then resolve the session id (`1234`) from the key and delete all the related session keys from Redis. + +One problem with relying on Redis expiration exclusively is that Redis makes no guarantee of when the expired event will be fired if the key has not been accessed. +For additional details see https://redis.io/commands/expire/#:~:text=How%20Redis%20expires%20keys[How Redis expires keys] in the Redis documentation. +To circumvent the fact that expired events are not guaranteed to happen we can ensure that each key is accessed when it is expected to expire. +This means that if the TTL is expired on the key, Redis will remove the key and fire the expired event when we try to access the key. +For this reason, each session expiration is also tracked by storing the session id in a sorted set ranked by its expiration time. +This allows a background task to access the potentially expired sessions to ensure that Redis expired events are fired in a more deterministic fashion. +For example: +---- +ZADD spring:session:sessions:expirations "1.702402961162E12" "648377f7-c76f-4f45-b847-c0268bb48381" +---- + +We do not explicitly delete the keys since in some instances there may be a race condition that incorrectly identifies a key as expired when it is not. +Short of using distributed locks (which would kill our performance) there is no way to ensure the consistency of the expiration mapping. +By simply accessing the key, we ensure that the key is only removed if the TTL on that key is expired. + +By default, Spring Session will retrieve up to 100 expired sessions every 60 seconds. +If you want to configure how often the cleanup task runs, please refer to the <> section. + +== Configuring Redis to Send Keyspace Events + +By default, Spring Session tries to configure Redis to send keyspace events using the `ConfigureNotifyKeyspaceEventsReactiveAction` which, in turn, might set the `notify-keyspace-events` configuration property to `Egx`. +However, this strategy will not work if the Redis instance has been properly secured. +In that case, the Redis instance should be configured externally and a Bean of type `ConfigureReactiveRedisAction.NO_OP` should be exposed to disable the autoconfiguration. + +[source,java] +---- +@Bean +public ConfigureReactiveRedisAction configureReactiveRedisAction() { + return ConfigureReactiveRedisAction.NO_OP; +} +---- + +[[changing-the-frequency-of-the-session-cleanup]] +== Changing the Frequency of the Session Cleanup + +Depending on your application's needs, you might want to change the frequency of the session cleanup. +To do that, you can expose a `ReactiveSessionRepositoryCustomizer` bean and set the `cleanupInterval` property: + +[source,java] +---- +@Bean +public ReactiveSessionRepositoryCustomizer reactiveSessionRepositoryCustomizer() { + return (sessionRepository) -> sessionRepository.setCleanupInterval(Duration.ofSeconds(30)); +} +---- + +You can also set invoke `disableCleanupTask()` to disable the cleanup task. + +[source,java] +---- +@Bean +public ReactiveSessionRepositoryCustomizer reactiveSessionRepositoryCustomizer() { + return (sessionRepository) -> sessionRepository.disableCleanupTask(); +} +---- + +[[taking-control-over-the-cleanup-task]] +=== Taking Control Over the Cleanup Task + +Sometimes, the default cleanup task might not be enough for your application's needs. +You might want to adopt a different strategy to clean up expired sessions. +Since you know that the <>, you can <> task and provide your own strategy. +For example: + +[source,java] +---- +@Component +public class SessionEvicter { + + private ReactiveRedisOperations redisOperations; + + @Scheduled + public Mono cleanup() { + Instant now = Instant.now(); + Instant oneMinuteAgo = now.minus(Duration.ofMinutes(1)); + Range range = Range.closed((double) oneMinuteAgo.toEpochMilli(), (double) now.toEpochMilli()); + Limit limit = Limit.limit().count(1000); + return this.redisOperations.opsForZSet().reverseRangeByScore("spring:session:sessions:expirations", range, limit) + // do something with the session ids + .then(); + } + +} +---- + +[[listening-session-events]] +== Listening to Session Events + +Often times it is valuable to react to session events, for example, you might want to do some kind of processing depending on the session lifecycle. + +You configure your application to listen to `SessionCreatedEvent`, `SessionDeletedEvent` and `SessionExpiredEvent` events. +There are a https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events[few ways to listen to application events] in Spring, for this example we are going to use the `@EventListener` annotation. + +==== +[source,java] +---- +@Component +public class SessionEventListener { + + @EventListener + public Mono processSessionCreatedEvent(SessionCreatedEvent event) { + // do the necessary work + } + + @EventListener + public Mono processSessionDeletedEvent(SessionDeletedEvent event) { + // do the necessary work + } + + @EventListener + public Mono processSessionExpiredEvent(SessionExpiredEvent event) { + // do the necessary work + } + +} +---- +==== diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/spring-session-sample-boot-reactive-redis-indexed.gradle b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/spring-session-sample-boot-reactive-redis-indexed.gradle new file mode 100644 index 00000000..d4412d8f --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/spring-session-sample-boot-reactive-redis-indexed.gradle @@ -0,0 +1,21 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + management platform(project(":spring-session-dependencies")) + implementation project(':spring-session-data-redis') + implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.seleniumhq.selenium:selenium-java' + testImplementation 'org.seleniumhq.selenium:htmlunit-driver' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/IndexController.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/IndexController.java new file mode 100644 index 00000000..8fdd7f72 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/IndexController.java @@ -0,0 +1,43 @@ +/* + * Copyright 2014-2023 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 com.example; + +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +class IndexController { + + private final ReactiveFindByIndexNameSessionRepository sessionRepository; + + IndexController(ReactiveFindByIndexNameSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @GetMapping("/") + Mono index(Model model, Authentication authentication) { + return this.sessionRepository.findByPrincipalName(authentication.getName()) + .doOnNext((sessions) -> model.addAttribute("sessions", sessions.values())) + .thenReturn("index"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SecurityConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SecurityConfig.java new file mode 100644 index 00000000..86a7d66c --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SecurityConfig.java @@ -0,0 +1,60 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.springframework.boot.autoconfigure.security.reactive.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.server.SecurityWebFilterChain; + +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +public class SecurityConfig { + + @Bean + SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + return http + .authorizeExchange(exchanges -> exchanges.matchers(PathRequest.toStaticResources().atCommonLocations()) + .permitAll() + .anyExchange() + .authenticated()) + .formLogin(Customizer.withDefaults()) + .build(); + } + + @Bean + MapReactiveUserDetailsService reactiveUserDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + UserDetails admin = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("USER", "ADMIN") + .build(); + return new MapReactiveUserDetailsService(user, admin); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SessionConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SessionConfig.java new file mode 100644 index 00000000..107566c1 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SessionConfig.java @@ -0,0 +1,26 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.springframework.context.annotation.Configuration; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession; + +@Configuration(proxyBeanMethods = false) +@EnableRedisIndexedWebSession +public class SessionConfig { + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplication.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplication.java new file mode 100644 index 00000000..16cff455 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringSessionSampleBootReactiveRedisIndexedApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringSessionSampleBootReactiveRedisIndexedApplication.class, args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/application.properties b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/application.properties new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/templates/index.html b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/templates/index.html new file mode 100644 index 00000000..816e88d8 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/main/resources/templates/index.html @@ -0,0 +1,16 @@ + + + Secured Content + + +
+

Secured Page

+

This page is secured using Spring Boot, Spring Session, and Spring Security.

+ + + + +
+
+ + diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/BasePage.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/BasePage.java new file mode 100644 index 00000000..30926c3b --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/BasePage.java @@ -0,0 +1,41 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.openqa.selenium.WebDriver; + +/** + * @author EddĂș MelĂ©ndez + */ +public class BasePage { + + private WebDriver driver; + + public BasePage(WebDriver driver) { + this.driver = driver; + } + + public WebDriver getDriver() { + return this.driver; + } + + public static void get(WebDriver driver, String get) { + String baseUrl = "http://localhost"; + driver.get(baseUrl + get); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/HomePage.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/HomePage.java new file mode 100644 index 00000000..fb55e271 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/HomePage.java @@ -0,0 +1,96 @@ +/* + * Copyright 2014-2023 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 com.example; + +import java.util.ArrayList; +import java.util.List; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HomePage { + + private WebDriver driver; + + @FindBy(css = "table tbody tr") + List trs; + + List attributes; + + public HomePage(WebDriver driver) { + this.driver = driver; + this.attributes = new ArrayList<>(); + } + + private static void get(WebDriver driver, int port, String get) { + String baseUrl = "http://localhost:" + port; + driver.get(baseUrl + get); + } + + public static LoginPage go(WebDriver driver, int port) { + get(driver, port, "/"); + return PageFactory.initElements(driver, LoginPage.class); + } + + public void assertAt() { + assertThat(this.driver.getTitle()).isEqualTo("Session Attributes"); + } + + public List attributes() { + List rows = new ArrayList<>(); + for (WebElement tr : this.trs) { + rows.add(new Attribute(tr)); + } + this.attributes.addAll(rows); + return this.attributes; + } + + public static class Attribute { + + @FindBy(xpath = ".//td[1]") + WebElement attributeName; + + @FindBy(xpath = ".//td[2]") + WebElement attributeValue; + + public Attribute(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + /** + * @return the attributeName + */ + public String getAttributeName() { + return this.attributeName.getText(); + } + + /** + * @return the attributeValue + */ + public String getAttributeValue() { + return this.attributeValue.getText(); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/LoginPage.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/LoginPage.java new file mode 100644 index 00000000..795e12e4 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/LoginPage.java @@ -0,0 +1,66 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class LoginPage extends BasePage { + + public LoginPage(WebDriver driver) { + super(driver); + } + + public void assertAt() { + assertThat(getDriver().getTitle()).isEqualTo("Please sign in"); + } + + public Form form() { + return new Form(getDriver()); + } + + public class Form { + + @FindBy(name = "username") + private WebElement username; + + @FindBy(name = "password") + private WebElement password; + + @FindBy(tagName = "button") + private WebElement button; + + public Form(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + public T login(Class page) { + this.username.sendKeys("user"); + this.password.sendKeys("password"); + this.button.click(); + return PageFactory.initElements(getDriver(), page); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTestApplication.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTestApplication.java new file mode 100644 index 00000000..b7307f10 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTestApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.TestConfiguration; + +@TestConfiguration(proxyBeanMethods = false) +public class SpringSessionSampleBootReactiveRedisIndexedApplicationTestApplication { + + public static void main(String[] args) { + SpringApplication.from(SpringSessionSampleBootReactiveRedisIndexedApplication::main) + .with(TestcontainersConfig.class) + .run(args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTests.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTests.java new file mode 100644 index 00000000..31a6cf1d --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/SpringSessionSampleBootReactiveRedisIndexedApplicationTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.htmlunit.HtmlUnitDriver; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestcontainersConfig.class) +class SpringSessionSampleBootReactiveRedisIndexedApplicationTests { + + WebDriver driver; + + @LocalServerPort + int serverPort; + + @BeforeEach + void setup() { + this.driver = new HtmlUnitDriver(); + } + + @AfterEach + void tearDown() { + this.driver.quit(); + } + + @Test + void indexWhenLoginThenShowSessionIds() { + LoginPage login = HomePage.go(this.driver, this.serverPort); + login.assertAt(); + HomePage home = login.form().login(HomePage.class); + assertThat(home.attributes()).hasSize(1); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/TestcontainersConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/TestcontainersConfig.java new file mode 100644 index 00000000..7be556a0 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-redis-indexed/src/test/java/com/example/TestcontainersConfig.java @@ -0,0 +1,35 @@ +/* + * Copyright 2014-2023 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 com.example; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; + +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfig { + + @Bean + @ServiceConnection(name = "redis") + GenericContainer redisContainer() { + return new GenericContainer<>(DockerImageName.parse("redis:6.2.6")).withExposedPorts(6379); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java b/spring-session-samples/spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java index b1a521bb..85523780 100644 --- a/spring-session-samples/spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java +++ b/spring-session-samples/spring-session-sample-boot-redis-json/src/main/java/sample/config/SessionConfig.java @@ -34,6 +34,11 @@ public class SessionConfig implements BeanClassLoaderAware { private ClassLoader loader; + /** + * Note that the bean name for this bean is intentionally + * {@code springSessionDefaultRedisSerializer}. It must be named this way to override + * the default {@link RedisSerializer} used by Spring Session. + */ @Bean public RedisSerializer springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(objectMapper());