Add ReactiveRedisIndexedSessionRepository

Closes gh-2700
This commit is contained in:
Marcus Da Coregio
2023-08-03 11:50:34 -03:00
committed by Marcus Hert Da Coregio
parent 3049318427
commit e65da12d82
34 changed files with 3646 additions and 8 deletions

View File

@@ -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);
}

View File

@@ -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<String, Object> 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<Instant> {
@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<ReactiveRedisIndexedSessionRepository> 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<Object> springSessionDefaultRedisSerializer() {
return RedisSerializer.json();
}
}
@Configuration(proxyBeanMethods = false)
@EnableRedisIndexedWebSession(maxInactiveIntervalInSeconds = 1)
@Import(AbstractRedisITests.BaseConfig.class)
static class DisableCleanupTaskAndNoKeyspaceEventsConfig {
@Bean
ReactiveSessionRepositoryCustomizer<ReactiveRedisIndexedSessionRepository> customizer() {
return ReactiveRedisIndexedSessionRepository::disableCleanupTask;
}
@Bean
ConfigureReactiveRedisAction configureReactiveRedisAction() {
return (connection) -> connection.serverCommands().setConfig("notify-keyspace-events", "").then();
}
}
}

View File

@@ -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<Object, Object> 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.<String>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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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<String, RedisSession> 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.<String>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.<String>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.<String>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();
}
}
}

View File

@@ -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}.
*
* <h2>Storage Details</h2> 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.
*
* <pre>
* 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"
* </pre>
*
* <h3>Saving a Session</h3>
*
* <p>
* Each session is stored in Redis as a
* <a href="https://redis.io/topics/data-types#hashes">Hash</a>. Each session is set and
* updated using the <a href="https://redis.io/commands/hmset">HMSET command</a>. An
* example of how each session is stored can be seen below.
* </p>
*
* <pre>
* HMSET spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 creationTime 1702400400000 maxInactiveInterval 1800 lastAccessedTime 1702400400000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
* </pre>
*
* <p>
* In this example, the session following statements are true about the session:
* </p>
* <ul>
* <li>The session id is 648377f7-c76f-4f45-b847-c0268bb48381</li>
* <li>The session was created at 1702400400000 in milliseconds since midnight of 1/1/1970
* GMT.</li>
* <li>The session expires in 1800 seconds (30 minutes).</li>
* <li>The session was last accessed at 1702400400000 in milliseconds since midnight of
* 1/1/1970 GMT.</li>
* <li>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".</li>
* </ul>
*
* <h3>Optimized Writes</h3>
*
* <p>
* 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:
* </p>
*
* <pre>
* HMSET spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 sessionAttr:attrName2 newValue
* </pre>
*
* <h3>SessionCreatedEvent</h3>
*
* <p>
* 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.
* </p>
*
* <h3>SessionDeletedEvent and SessionExpiredEvent</h3> 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.
*
* <h3>Expiration</h3>
*
* <p>
* An expiration is associated to each session using the
* <a href="https://redis.io/commands/expire">EXPIRE command</a> based upon the
* {@link ReactiveRedisIndexedSessionRepository.RedisSession#getMaxInactiveInterval()} .
* For example:
* </p>
*
* <pre>
* EXPIRE spring:session:sessions:648377f7-c76f-4f45-b847-c0268bb48381 2100
* </pre>
*
* <p>
* 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.
* </p>
*
* <p>
* <b>NOTE:</b> 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.
* </p>
*
* <p>
* Spring Session relies on the expired and delete
* <a href="https://redis.io/docs/manual/keyspace-notifications/">keyspace
* notifications</a> 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.
* </p>
*
* <p>
* 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:
* </p>
*
* <pre>
* APPEND spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 ""
* EXPIRE spring:session:sessions:expires:648377f7-c76f-4f45-b847-c0268bb48381 1800
* </pre>
*
* <p>
* When a session key is deleted or expires, the keyspace notification triggers a lookup
* of the actual session and a {@link SessionDestroyedEvent} is fired.
* </p>
*
* <p>
* 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 <a href="https://redis.io/commands/expire/">How Redis expires
* keys</a> section in the Redis Expire documentation.
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* 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:
* </p>
*
* <pre>
* ZADD spring:session:sessions:expirations "1.702402961162E12" "648377f7-c76f-4f45-b847-c0268bb48381"
* </pre>
*
* <p>
* <b>NOTE</b>: 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.
* </p>
*
* <h3>Secondary Indexes</h3> 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: <pre>
* 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"
* </pre>
*
* 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<ReactiveRedisIndexedSessionRepository.RedisSession>,
ReactiveFindByIndexNameSessionRepository<ReactiveRedisIndexedSessionRepository.RedisSession>, 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<String, Object> sessionRedisOperations;
private final ReactiveRedisTemplate<String, String> keyEventsOperations;
private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance();
private BiFunction<String, Map<String, Object>, Mono<MapSession>> 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<Disposable> 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<String, Object> sessionRedisOperations,
ReactiveRedisTemplate<String, String> 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<Void> cleanUpExpiredSessions() {
return this.expirationStore.retrieveExpiredSessions(this.clock.instant()).flatMap(this::touch);
}
private Mono<Void> 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<Map<String, RedisSession>> findByIndexNameAndIndexValue(String indexName, String indexValue) {
return this.indexer.getSessionIds(indexName, indexValue)
.flatMap(this::findById)
.collectMap(RedisSession::getId);
}
@Override
public Mono<RedisSession> 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<Void> 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<RedisSession> findById(String id) {
return getSession(id, false);
}
private Mono<RedisSession> 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<Void> 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<Void> onSessionCreatedChannelMessage(ReactiveSubscription.Message<String, Object> 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<String, Object> entries = (Map<String, Object>) 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<Void> onKeyDestroyedMessage(ReactiveSubscription.Message<String, String> 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<String, Map<String, Object>, Mono<MapSession>> 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<String, Object> getSessionRedisOperations() {
return this.sessionRedisOperations;
}
public void setEventPublisher(ApplicationEventPublisher eventPublisher) {
Assert.notNull(eventPublisher, "eventPublisher cannot be null");
this.eventPublisher = eventPublisher;
}
public void setIndexResolver(IndexResolver<Session> 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<String, Object> delta = new HashMap<>();
private boolean isNew;
private String originalSessionId;
private Map<String, String> 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> 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<String> 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<String, String> getIndexes() {
return Collections.unmodifiableMap(this.indexes);
}
private boolean hasChangedSessionId() {
return !getId().equals(this.originalSessionId);
}
private Mono<Void> save() {
return Mono
.defer(() -> saveChangeSessionId().then(saveDelta()).doOnSuccess((unused) -> this.isNew = false));
}
private Mono<Void> saveDelta() {
if (this.delta.isEmpty()) {
return Mono.empty();
}
String sessionKey = getSessionKey(getId());
Mono<Boolean> update = ReactiveRedisIndexedSessionRepository.this.sessionRedisOperations.opsForHash()
.putAll(sessionKey, new HashMap<>(this.delta));
String expiredKey = getExpiredKey(getId());
Mono<Boolean> setTtl;
Mono<Boolean> 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<Void> 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<Void> saveChangeSessionId() {
if (!hasChangedSessionId()) {
return Mono.empty();
}
String sessionId = getId();
Mono<Void> 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<Void> replaceSessionIdOnIndexes() {
return ReactiveRedisIndexedSessionRepository.this.indexer.delete(this.originalSessionId)
.then(ReactiveRedisIndexedSessionRepository.this.indexer.update(this));
}
private Mono<Void> 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<String, Map<String, Object>, Mono<MapSession>> {
private final RedisSessionMapper mapper = new RedisSessionMapper();
@Override
public Mono<MapSession> apply(String sessionId, Map<String, Object> map) {
return Mono.fromSupplier(() -> this.mapper.apply(sessionId, map));
}
}
}

View File

@@ -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<String, Object> sessionRedisOperations;
private String namespace;
private IndexResolver<Session> indexResolver = new DelegatingIndexResolver<>(
new PrincipalNameIndexResolver<>(ReactiveRedisIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME));
private String indexKeyPrefix;
ReactiveRedisSessionIndexer(ReactiveRedisOperations<String, Object> 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<Void> update(RedisSession redisSession) {
return getIndexes(redisSession.getId()).map((originalIndexes) -> {
Map<String, String> indexes = this.indexResolver.resolveIndexesFor(redisSession);
Map<String, String> indexToDelete = new HashMap<>();
Map<String, String> indexToAdd = new HashMap<>();
for (Map.Entry<String, String> 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<Void> updateIndexes(Map<String, String> indexToDelete, Map<String, String> 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<Void> 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<Void> removeSessionFromIndex(String indexKey, String sessionId) {
return this.sessionRedisOperations.opsForSet().remove(indexKey, sessionId).then();
}
Mono<Map<String, String>> 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<String> 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<Session> indexResolver) {
Assert.notNull(indexResolver, "indexResolver cannot be null");
this.indexResolver = indexResolver;
}
}

View File

@@ -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<String, Object> sessionRedisOperations;
private String namespace;
private int retrieveCount = 100;
SortedSetReactiveRedisSessionExpirationStore(ReactiveRedisOperations<String, Object> 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<Void> 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<Void> 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<String> retrieveExpiredSessions(Instant expiredBefore) {
Range<Double> 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;
}
}

View File

@@ -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<Void> configure(ReactiveRedisConnection connection);
/**
* An implementation of {@link ConfigureReactiveRedisAction} that does nothing.
*/
ConfigureReactiveRedisAction NO_OP = (connection) -> Mono.empty();
}

View File

@@ -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;
/**
* <p>
* Ensures that Redis Keyspace events for Generic commands and Expired events are enabled.
* For example, it might set the following:
* </p>
*
* <pre>
* config set notify-keyspace-events Egx
* </pre>
*
* <p>
* 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.
* </p>
*
* @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<Void> 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<String> 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));
}
}

View File

@@ -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<T extends ReactiveSessionRepository<? extends Session>> {
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<Object> defaultRedisSerializer = new JdkSerializationRedisSerializer();
private List<ReactiveSessionRepositoryCustomizer<T>> 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<Object> getDefaultRedisSerializer() {
return this.defaultRedisSerializer;
}
@Autowired
public void setRedisConnectionFactory(
@SpringSessionRedisConnectionFactory ObjectProvider<ReactiveRedisConnectionFactory> springSessionRedisConnectionFactory,
ObjectProvider<ReactiveRedisConnectionFactory> redisConnectionFactory) {
ReactiveRedisConnectionFactory redisConnectionFactoryToUse = springSessionRedisConnectionFactory
.getIfAvailable();
if (redisConnectionFactoryToUse == null) {
redisConnectionFactoryToUse = redisConnectionFactory.getObject();
}
this.redisConnectionFactory = redisConnectionFactoryToUse;
}
@Autowired(required = false)
@Qualifier("springSessionDefaultRedisSerializer")
public void setDefaultRedisSerializer(RedisSerializer<Object> defaultRedisSerializer) {
this.defaultRedisSerializer = defaultRedisSerializer;
}
@Autowired(required = false)
public void setSessionRepositoryCustomizer(
ObjectProvider<ReactiveSessionRepositoryCustomizer<T>> sessionRepositoryCustomizers) {
this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList());
}
protected List<ReactiveSessionRepositoryCustomizer<T>> getSessionRepositoryCustomizers() {
return this.sessionRepositoryCustomizers;
}
protected ReactiveRedisTemplate<String, Object> createReactiveRedisTemplate() {
RedisSerializer<String> keySerializer = RedisSerializer.string();
RedisSerializer<Object> defaultSerializer = (this.defaultRedisSerializer != null) ? this.defaultRedisSerializer
: new JdkSerializationRedisSerializer();
RedisSerializationContext<String, Object> serializationContext = RedisSerializationContext
.<String, Object>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;
}
}

View File

@@ -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:
*
* <pre class="code">
* &#064;Configuration(proxyBeanMethods = false)
* &#064;EnableRedisIndexedWebSession
* public class RedisIndexedWebSessionConfig {
*
* &#064;Bean
* public LettuceConnectionFactory redisConnectionFactory() {
* return new LettuceConnectionFactory();
* }
*
* }
* </pre>
*
* 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 <redisNamespace>:}.
* <p>
* 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;
}

View File

@@ -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<ReactiveRedisIndexedSessionRepository>
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<Session> 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<String, Object> 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<Session> indexResolver) {
this.indexResolver = indexResolver;
}
@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.embeddedValueResolver = resolver;
}
@Override
public void setImportMetadata(AnnotationMetadata importMetadata) {
Map<String, Object> attributeMap = importMetadata
.getAnnotationAttributes(EnableRedisIndexedWebSession.class.getName());
AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributeMap);
if (attributes == null) {
return;
}
setMaxInactiveInterval(Duration.ofSeconds(attributes.<Integer>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);
}
}
}
}
}

View File

@@ -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<String, Object> 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<Object> indexKeys = List.of(this.indexKeyPrefix + "principalName:user",
this.indexKeyPrefix + "index_name:index_value");
given(this.sessionRedisOperations.opsForSet().members(anyString())).willReturn(Flux.fromIterable(indexKeys));
Map<String, String> 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<Object> 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<Session> 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<S extends Session> extends SingleIndexResolver<S> {
protected TestIndexResolver(String indexName) {
super(indexName);
}
@Override
public String resolveIndexValueFor(S session) {
return session.getAttribute(getIndexName());
}
}
}

View File

@@ -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<String, Object> 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<Range<Double>> rangeCaptor = ArgumentCaptor.forClass(Range.class);
ArgumentCaptor<Limit> 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);
}
}