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());