Skip to content

Commit

Permalink
Add ReactiveRedisIndexedSessionRepository
Browse files Browse the repository at this point in the history
  • Loading branch information
marcusdacoregio committed Dec 19, 2023
1 parent 9529c97 commit 3ff0f0b
Show file tree
Hide file tree
Showing 34 changed files with 3,609 additions and 8 deletions.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,6 @@ org-springframework-spring-framework-bom = "org.springframework:spring-framework
org-springframework-boot-spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "org-springframework-boot" }
org-springframework-boot-spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "org-springframework-boot" }
org-testcontainers-testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "org-testcontainers" }
org-awaitility-awaitility = "org.awaitility:awaitility:4.2.0"

[plugins]
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ public PrincipalNameIndexResolver() {
super(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME);
}

/**
* Create a new instance specifying the name of the index to be resolved.
* @param indexName the name of the index to be resolved
* @since 3.3
*/
public PrincipalNameIndexResolver(String indexName) {
super(indexName);
}

public String resolveIndexValueFor(S session) {
String principalName = session.getAttribute(getIndexName());
if (principalName != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2014-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.session;

import java.util.Map;

import reactor.core.publisher.Mono;

/**
* Allow finding sessions by the specified index name and index value.
*
* @param <S> the type of Session being managed by this
* {@link ReactiveFindByIndexNameSessionRepository}
* @author Marcus da Coregio
* @since 3.3
*/
public interface ReactiveFindByIndexNameSessionRepository<S extends Session> {

/**
* A session index that contains the current principal name (i.e. username).
* <p>
* It is the responsibility of the developer to ensure the index is populated since
* Spring Session is not aware of the authentication mechanism being used.
*/
String PRINCIPAL_NAME_INDEX_NAME = "PRINCIPAL_NAME_INDEX_NAME";

/**
* Find a {@link Map} of the session id to the {@link Session} of all sessions that
* contain the specified index name index value.
* @param indexName the name of the index (i.e. {@link #PRINCIPAL_NAME_INDEX_NAME})
* @param indexValue the value of the index to search for.
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
*/
Mono<Map<String, S>> findByIndexNameAndIndexValue(String indexName, String indexValue);

/**
* A shortcut for {@link #findByIndexNameAndIndexValue(String, String)} that uses
* {@link #PRINCIPAL_NAME_INDEX_NAME} for the index name.
* @param principalName the principal name
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
*/
default Mono<Map<String, S>> findByPrincipalName(String principalName) {
return findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName);
}

}
4 changes: 3 additions & 1 deletion spring-session-data-redis/spring-session-data-redis.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ dependencies {
testImplementation "org.springframework:spring-web"
testImplementation "org.springframework.security:spring-security-core"
testImplementation "org.junit.jupiter:junit-jupiter-api"
testImplementation "org.awaitility:awaitility"
testImplementation "io.lettuce:lettuce-core"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine"

integrationTestCompile "io.lettuce:lettuce-core"
integrationTestCompile "org.testcontainers:testcontainers"
integrationTestCompile "com.redis:testcontainers-redis:1.7.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* 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.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);
}

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

}

}
Loading

0 comments on commit 3ff0f0b

Please sign in to comment.