Skip to content

Commit 34c8a23

Browse files
Add ReactiveRedisIndexedSessionRepository
Closes spring-projectsgh-2700
1 parent 9529c97 commit 34c8a23

File tree

34 files changed

+3646
-8
lines changed

34 files changed

+3646
-8
lines changed

gradle/libs.versions.toml

+1
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,6 @@ org-springframework-spring-framework-bom = "org.springframework:spring-framework
6363
org-springframework-boot-spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "org-springframework-boot" }
6464
org-springframework-boot-spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "org-springframework-boot" }
6565
org-testcontainers-testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "org-testcontainers" }
66+
org-awaitility-awaitility = "org.awaitility:awaitility:4.2.0"
6667

6768
[plugins]

spring-session-core/src/main/java/org/springframework/session/PrincipalNameIndexResolver.java

+9
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ public PrincipalNameIndexResolver() {
3838
super(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME);
3939
}
4040

41+
/**
42+
* Create a new instance specifying the name of the index to be resolved.
43+
* @param indexName the name of the index to be resolved
44+
* @since 3.3
45+
*/
46+
public PrincipalNameIndexResolver(String indexName) {
47+
super(indexName);
48+
}
49+
4150
public String resolveIndexValueFor(S session) {
4251
String principalName = session.getAttribute(getIndexName());
4352
if (principalName != null) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2014-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.session;
18+
19+
import java.util.Map;
20+
21+
import reactor.core.publisher.Mono;
22+
23+
/**
24+
* Allow finding sessions by the specified index name and index value.
25+
*
26+
* @param <S> the type of Session being managed by this
27+
* {@link ReactiveFindByIndexNameSessionRepository}
28+
* @author Marcus da Coregio
29+
* @since 3.3
30+
*/
31+
public interface ReactiveFindByIndexNameSessionRepository<S extends Session> {
32+
33+
/**
34+
* A session index that contains the current principal name (i.e. username).
35+
* <p>
36+
* It is the responsibility of the developer to ensure the index is populated since
37+
* Spring Session is not aware of the authentication mechanism being used.
38+
*/
39+
String PRINCIPAL_NAME_INDEX_NAME = "PRINCIPAL_NAME_INDEX_NAME";
40+
41+
/**
42+
* Find a {@link Map} of the session id to the {@link Session} of all sessions that
43+
* contain the specified index name index value.
44+
* @param indexName the name of the index (i.e. {@link #PRINCIPAL_NAME_INDEX_NAME})
45+
* @param indexValue the value of the index to search for.
46+
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
47+
*/
48+
Mono<Map<String, S>> findByIndexNameAndIndexValue(String indexName, String indexValue);
49+
50+
/**
51+
* A shortcut for {@link #findByIndexNameAndIndexValue(String, String)} that uses
52+
* {@link #PRINCIPAL_NAME_INDEX_NAME} for the index name.
53+
* @param principalName the principal name
54+
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
55+
*/
56+
default Mono<Map<String, S>> findByPrincipalName(String principalName) {
57+
return findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName);
58+
}
59+
60+
}

spring-session-data-redis/spring-session-data-redis.gradle

+3-1
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ dependencies {
2121
testImplementation "org.springframework:spring-web"
2222
testImplementation "org.springframework.security:spring-security-core"
2323
testImplementation "org.junit.jupiter:junit-jupiter-api"
24+
testImplementation "org.awaitility:awaitility"
25+
testImplementation "io.lettuce:lettuce-core"
2426
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine"
2527

26-
integrationTestCompile "io.lettuce:lettuce-core"
2728
integrationTestCompile "org.testcontainers:testcontainers"
29+
integrationTestCompile "com.redis:testcontainers-redis:1.7.0"
2830
}

spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/AbstractRedisITests.java

+7-6
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
package org.springframework.session.data.redis;
1818

19-
import org.testcontainers.containers.GenericContainer;
19+
import com.redis.testcontainers.RedisContainer;
2020

2121
import org.springframework.context.annotation.Bean;
2222
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
@@ -34,16 +34,17 @@ public abstract class AbstractRedisITests {
3434
protected static class BaseConfig {
3535

3636
@Bean
37-
public GenericContainer redisContainer() {
38-
GenericContainer redisContainer = new GenericContainer(DOCKER_IMAGE).withExposedPorts(6379);
37+
public RedisContainer redisContainer() {
38+
RedisContainer redisContainer = new RedisContainer(
39+
RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG));
3940
redisContainer.start();
4041
return redisContainer;
4142
}
4243

4344
@Bean
44-
public LettuceConnectionFactory redisConnectionFactory() {
45-
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisContainer().getHost(),
46-
redisContainer().getFirstMappedPort());
45+
public LettuceConnectionFactory redisConnectionFactory(RedisContainer redisContainer) {
46+
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisContainer.getHost(),
47+
redisContainer.getFirstMappedPort());
4748
return new LettuceConnectionFactory(configuration);
4849
}
4950

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*
2+
* Copyright 2014-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.session.data.redis;
18+
19+
import java.time.Duration;
20+
import java.time.Instant;
21+
import java.time.temporal.ChronoUnit;
22+
import java.util.Comparator;
23+
import java.util.UUID;
24+
25+
import org.junit.jupiter.api.BeforeEach;
26+
import org.junit.jupiter.api.Test;
27+
import org.junit.jupiter.api.extension.ExtendWith;
28+
29+
import org.springframework.context.annotation.Bean;
30+
import org.springframework.context.annotation.Configuration;
31+
import org.springframework.context.annotation.Import;
32+
import org.springframework.data.redis.core.ReactiveRedisOperations;
33+
import org.springframework.data.redis.serializer.RedisSerializer;
34+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
35+
import org.springframework.security.core.authority.AuthorityUtils;
36+
import org.springframework.security.core.context.SecurityContext;
37+
import org.springframework.security.core.context.SecurityContextHolder;
38+
import org.springframework.session.ReactiveFindByIndexNameSessionRepository;
39+
import org.springframework.session.Session;
40+
import org.springframework.session.config.ReactiveSessionRepositoryCustomizer;
41+
import org.springframework.session.data.SessionEventRegistry;
42+
import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession;
43+
import org.springframework.session.data.redis.config.ConfigureReactiveRedisAction;
44+
import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession;
45+
import org.springframework.session.events.SessionCreatedEvent;
46+
import org.springframework.test.context.junit.jupiter.SpringExtension;
47+
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
48+
49+
import static org.assertj.core.api.Assertions.assertThat;
50+
import static org.awaitility.Awaitility.await;
51+
52+
@ExtendWith(SpringExtension.class)
53+
class ReactiveRedisIndexedSessionRepositoryConfigurationITests {
54+
55+
ReactiveRedisIndexedSessionRepository repository;
56+
57+
ReactiveRedisOperations<String, Object> sessionRedisOperations;
58+
59+
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
60+
61+
SecurityContext securityContext;
62+
63+
@BeforeEach
64+
void setup() {
65+
this.securityContext = SecurityContextHolder.createEmptyContext();
66+
this.securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("username-" + UUID.randomUUID(),
67+
"na", AuthorityUtils.createAuthorityList("ROLE_USER")));
68+
}
69+
70+
@Test
71+
void cleanUpTaskWhenSessionIsExpiredThenAllRelatedKeysAreDeleted() {
72+
registerConfig(OneSecCleanUpIntervalConfig.class);
73+
RedisSession session = this.repository.createSession().block();
74+
session.setAttribute("SPRING_SECURITY_CONTEXT", this.securityContext);
75+
this.repository.save(session).block();
76+
await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> {
77+
assertThat(this.repository.findById(session.getId()).block()).isNull();
78+
Boolean hasSessionKey = this.sessionRedisOperations.hasKey("spring:session:sessions:" + session.getId())
79+
.block();
80+
Boolean hasSessionIndexesKey = this.sessionRedisOperations
81+
.hasKey("spring:session:sessions:" + session.getId() + ":idx")
82+
.block();
83+
Boolean hasPrincipalIndexKey = this.sessionRedisOperations
84+
.hasKey("spring:session:sessions:index:"
85+
+ ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":"
86+
+ this.securityContext.getAuthentication().getName())
87+
.block();
88+
Long expirationsSize = this.sessionRedisOperations.opsForZSet()
89+
.size("spring:session:sessions:expirations")
90+
.block();
91+
assertThat(hasSessionKey).isFalse();
92+
assertThat(hasSessionIndexesKey).isFalse();
93+
assertThat(hasPrincipalIndexKey).isFalse();
94+
assertThat(expirationsSize).isZero();
95+
});
96+
}
97+
98+
@Test
99+
void onSessionCreatedWhenUsingJsonSerializerThenEventDeserializedCorrectly() throws InterruptedException {
100+
registerConfig(SessionEventRegistryJsonSerializerConfig.class);
101+
RedisSession session = this.repository.createSession().block();
102+
this.repository.save(session).block();
103+
SessionEventRegistry registry = this.context.getBean(SessionEventRegistry.class);
104+
SessionCreatedEvent event = registry.getEvent(session.getId());
105+
Session eventSession = event.getSession();
106+
assertThat(eventSession).usingRecursiveComparison()
107+
.withComparatorForFields(new InstantComparator(), "cached.creationTime", "cached.lastAccessedTime")
108+
.isEqualTo(session);
109+
}
110+
111+
@Test
112+
void sessionExpiredWhenNoCleanUpTaskAndNoKeyspaceEventsThenNoCleanup() {
113+
registerConfig(DisableCleanupTaskAndNoKeyspaceEventsConfig.class);
114+
RedisSession session = this.repository.createSession().block();
115+
this.repository.save(session).block();
116+
await().during(Duration.ofSeconds(3)).untilAsserted(() -> {
117+
Boolean exists = this.sessionRedisOperations.hasKey("spring:session:sessions:" + session.getId()).block();
118+
assertThat(exists).isTrue();
119+
});
120+
}
121+
122+
private void registerConfig(Class<?> clazz) {
123+
this.context.register(clazz);
124+
this.context.refresh();
125+
this.repository = this.context.getBean(ReactiveRedisIndexedSessionRepository.class);
126+
this.sessionRedisOperations = this.repository.getSessionRedisOperations();
127+
}
128+
129+
static class InstantComparator implements Comparator<Instant> {
130+
131+
@Override
132+
public int compare(Instant o1, Instant o2) {
133+
return o1.truncatedTo(ChronoUnit.SECONDS).compareTo(o2.truncatedTo(ChronoUnit.SECONDS));
134+
}
135+
136+
}
137+
138+
@Configuration(proxyBeanMethods = false)
139+
@EnableRedisIndexedWebSession(maxInactiveIntervalInSeconds = 1)
140+
@Import(AbstractRedisITests.BaseConfig.class)
141+
static class OneSecCleanUpIntervalConfig {
142+
143+
@Bean
144+
ReactiveSessionRepositoryCustomizer<ReactiveRedisIndexedSessionRepository> customizer() {
145+
return (sessionRepository) -> sessionRepository.setCleanupInterval(Duration.ofSeconds(1));
146+
}
147+
148+
}
149+
150+
@Configuration(proxyBeanMethods = false)
151+
@EnableRedisIndexedWebSession
152+
@Import(AbstractRedisITests.BaseConfig.class)
153+
static class SessionEventRegistryJsonSerializerConfig {
154+
155+
@Bean
156+
SessionEventRegistry sessionEventRegistry() {
157+
return new SessionEventRegistry();
158+
}
159+
160+
@Bean
161+
RedisSerializer<Object> springSessionDefaultRedisSerializer() {
162+
return RedisSerializer.json();
163+
}
164+
165+
}
166+
167+
@Configuration(proxyBeanMethods = false)
168+
@EnableRedisIndexedWebSession(maxInactiveIntervalInSeconds = 1)
169+
@Import(AbstractRedisITests.BaseConfig.class)
170+
static class DisableCleanupTaskAndNoKeyspaceEventsConfig {
171+
172+
@Bean
173+
ReactiveSessionRepositoryCustomizer<ReactiveRedisIndexedSessionRepository> customizer() {
174+
return ReactiveRedisIndexedSessionRepository::disableCleanupTask;
175+
}
176+
177+
@Bean
178+
ConfigureReactiveRedisAction configureReactiveRedisAction() {
179+
return (connection) -> connection.serverCommands().setConfig("notify-keyspace-events", "").then();
180+
}
181+
182+
}
183+
184+
}

0 commit comments

Comments
 (0)