Skip to content

Commit 223a90f

Browse files
Add SpringSessionBackedReactiveSessionRegistry
Closes gh-2824
1 parent 203a100 commit 223a90f

File tree

19 files changed

+997
-1
lines changed

19 files changed

+997
-1
lines changed

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ org-slf4j-jcl-over-slf4j = { module = "org.slf4j:jcl-over-slf4j", version.ref =
5858
org-slf4j-log4j-over-slf4j = { module = "org.slf4j:log4j-over-slf4j", version.ref = "org-slf4j" }
5959
org-slf4j-slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "org-slf4j" }
6060
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.0.0-M1"
61-
org-springframework-security-spring-security-bom = "org.springframework.security:spring-security-bom:6.3.0-M2"
61+
org-springframework-security-spring-security-bom = "org.springframework.security:spring-security-bom:6.3.0-SNAPSHOT"
6262
org-springframework-spring-framework-bom = "org.springframework:spring-framework-bom:6.1.4"
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" }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2014-2024 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.security;
18+
19+
import reactor.core.publisher.Flux;
20+
import reactor.core.publisher.Mono;
21+
22+
import org.springframework.security.authentication.AbstractAuthenticationToken;
23+
import org.springframework.security.core.Authentication;
24+
import org.springframework.security.core.authority.AuthorityUtils;
25+
import org.springframework.security.core.context.SecurityContext;
26+
import org.springframework.security.core.session.ReactiveSessionInformation;
27+
import org.springframework.security.core.session.ReactiveSessionRegistry;
28+
import org.springframework.session.ReactiveFindByIndexNameSessionRepository;
29+
import org.springframework.session.ReactiveSessionRepository;
30+
import org.springframework.session.Session;
31+
import org.springframework.util.Assert;
32+
33+
/**
34+
* A {@link ReactiveSessionRegistry} that retrieves session information from Spring
35+
* Session, rather than maintaining it itself. This allows concurrent session management
36+
* with Spring Security in a clustered environment.
37+
* <p>
38+
* Relies on being able to derive the same String-based representation of the principal
39+
* given to {@link #getAllSessions(Object)} as used by Spring Session in order to look up
40+
* the user's sessions.
41+
* <p>
42+
*
43+
* @param <S> the {@link Session} type.
44+
* @author Marcus da Coregio
45+
* @since 3.3
46+
*/
47+
public final class SpringSessionBackedReactiveSessionRegistry<S extends Session> implements ReactiveSessionRegistry {
48+
49+
private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";
50+
51+
private final ReactiveSessionRepository<S> sessionRepository;
52+
53+
private final ReactiveFindByIndexNameSessionRepository<S> indexedSessionRepository;
54+
55+
public SpringSessionBackedReactiveSessionRegistry(ReactiveSessionRepository<S> sessionRepository,
56+
ReactiveFindByIndexNameSessionRepository<S> indexedSessionRepository) {
57+
Assert.notNull(sessionRepository, "sessionRepository cannot be null");
58+
Assert.notNull(indexedSessionRepository, "indexedSessionRepository cannot be null");
59+
this.sessionRepository = sessionRepository;
60+
this.indexedSessionRepository = indexedSessionRepository;
61+
}
62+
63+
@Override
64+
public Flux<ReactiveSessionInformation> getAllSessions(Object principal) {
65+
Authentication authenticationToken = getAuthenticationToken(principal);
66+
return this.indexedSessionRepository.findByPrincipalName(authenticationToken.getName())
67+
.flatMapMany((sessionMap) -> Flux.fromIterable(sessionMap.entrySet()))
68+
.map((entry) -> new SpringSessionBackedReactiveSessionInformation(entry.getValue()));
69+
}
70+
71+
@Override
72+
public Mono<Void> saveSessionInformation(ReactiveSessionInformation information) {
73+
return Mono.empty();
74+
}
75+
76+
@Override
77+
public Mono<ReactiveSessionInformation> getSessionInformation(String sessionId) {
78+
return this.sessionRepository.findById(sessionId).map(SpringSessionBackedReactiveSessionInformation::new);
79+
}
80+
81+
@Override
82+
public Mono<ReactiveSessionInformation> removeSessionInformation(String sessionId) {
83+
return Mono.empty();
84+
}
85+
86+
@Override
87+
public Mono<ReactiveSessionInformation> updateLastAccessTime(String sessionId) {
88+
return Mono.empty();
89+
}
90+
91+
private static Authentication getAuthenticationToken(Object principal) {
92+
return new AbstractAuthenticationToken(AuthorityUtils.NO_AUTHORITIES) {
93+
94+
@Override
95+
public Object getCredentials() {
96+
return null;
97+
}
98+
99+
@Override
100+
public Object getPrincipal() {
101+
return principal;
102+
}
103+
104+
};
105+
}
106+
107+
class SpringSessionBackedReactiveSessionInformation extends ReactiveSessionInformation {
108+
109+
SpringSessionBackedReactiveSessionInformation(S session) {
110+
super(resolvePrincipalName(session), session.getId(), session.getLastAccessedTime());
111+
}
112+
113+
private static String resolvePrincipalName(Session session) {
114+
String principalName = session
115+
.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME);
116+
if (principalName != null) {
117+
return principalName;
118+
}
119+
SecurityContext securityContext = session.getAttribute(SPRING_SECURITY_CONTEXT);
120+
if (securityContext != null && securityContext.getAuthentication() != null) {
121+
return securityContext.getAuthentication().getName();
122+
}
123+
return "";
124+
}
125+
126+
@Override
127+
public Mono<Void> invalidate() {
128+
return super.invalidate()
129+
.then(Mono.defer(() -> SpringSessionBackedReactiveSessionRegistry.this.sessionRepository
130+
.deleteById(getSessionId())));
131+
}
132+
133+
}
134+
135+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Copyright 2014-2024 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.security;
18+
19+
import java.util.Map;
20+
import java.util.concurrent.ConcurrentHashMap;
21+
22+
import org.junit.jupiter.api.BeforeEach;
23+
import org.junit.jupiter.api.Test;
24+
import reactor.core.publisher.Flux;
25+
import reactor.core.publisher.Mono;
26+
import reactor.test.StepVerifier;
27+
28+
import org.springframework.security.authentication.TestingAuthenticationToken;
29+
import org.springframework.security.core.context.SecurityContextImpl;
30+
import org.springframework.security.core.session.ReactiveSessionInformation;
31+
import org.springframework.session.MapSession;
32+
import org.springframework.session.ReactiveFindByIndexNameSessionRepository;
33+
import org.springframework.session.ReactiveMapSessionRepository;
34+
35+
import static org.assertj.core.api.Assertions.assertThat;
36+
37+
class SpringSessionBackedReactiveSessionRegistryTests {
38+
39+
static MapSession johnSession1 = new MapSession();
40+
static MapSession johnSession2 = new MapSession();
41+
static MapSession johnSession3 = new MapSession();
42+
43+
SpringSessionBackedReactiveSessionRegistry<MapSession> sessionRegistry;
44+
45+
ReactiveFindByIndexNameSessionRepository<MapSession> indexedSessionRepository = new StubIndexedSessionRepository();
46+
47+
ReactiveMapSessionRepository sessionRepository = new ReactiveMapSessionRepository(new ConcurrentHashMap<>());
48+
49+
static {
50+
johnSession1.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe");
51+
johnSession2.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe");
52+
johnSession3.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe");
53+
}
54+
55+
@BeforeEach
56+
void setup() {
57+
this.sessionRegistry = new SpringSessionBackedReactiveSessionRegistry<>(this.sessionRepository,
58+
this.indexedSessionRepository);
59+
this.sessionRepository.save(johnSession1).block();
60+
this.sessionRepository.save(johnSession2).block();
61+
this.sessionRepository.save(johnSession3).block();
62+
}
63+
64+
@Test
65+
void saveSessionInformationThenDoNothing() {
66+
StepVerifier.create(this.sessionRegistry.saveSessionInformation(null)).expectComplete().verify();
67+
}
68+
69+
@Test
70+
void removeSessionInformationThenDoNothing() {
71+
StepVerifier.create(this.sessionRegistry.removeSessionInformation(null)).expectComplete().verify();
72+
}
73+
74+
@Test
75+
void updateLastAccessTimeThenDoNothing() {
76+
StepVerifier.create(this.sessionRegistry.updateLastAccessTime(null)).expectComplete().verify();
77+
}
78+
79+
@Test
80+
void getSessionInformationWhenPrincipalIndexNamePresentThenPrincipalResolved() {
81+
MapSession session = this.sessionRepository.createSession().block();
82+
session.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe");
83+
this.sessionRepository.save(session).block();
84+
StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId()))
85+
.assertNext((sessionInformation) -> {
86+
assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId());
87+
assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime());
88+
assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe");
89+
})
90+
.verifyComplete();
91+
}
92+
93+
@Test
94+
void getSessionInformationWhenSecurityContextAttributePresentThenPrincipalResolved() {
95+
MapSession session = this.sessionRepository.createSession().block();
96+
TestingAuthenticationToken authentication = new TestingAuthenticationToken("johndoe", "n/a");
97+
SecurityContextImpl securityContext = new SecurityContextImpl();
98+
securityContext.setAuthentication(authentication);
99+
session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext);
100+
this.sessionRepository.save(session).block();
101+
StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId()))
102+
.assertNext((sessionInformation) -> {
103+
assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId());
104+
assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime());
105+
assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe");
106+
})
107+
.verifyComplete();
108+
}
109+
110+
@Test
111+
void getSessionInformationWhenNoResolvablePrincipalThenPrincipalBlank() {
112+
MapSession session = this.sessionRepository.createSession().block();
113+
this.sessionRepository.save(session).block();
114+
StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId()))
115+
.assertNext((sessionInformation) -> {
116+
assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId());
117+
assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime());
118+
assertThat(sessionInformation.getPrincipal()).isEqualTo("");
119+
})
120+
.verifyComplete();
121+
}
122+
123+
@Test
124+
void getSessionInformationWhenInvalidateThenRemovedFromSessionRepository() {
125+
MapSession session = this.sessionRepository.createSession().block();
126+
this.sessionRepository.save(session).block();
127+
Mono<Void> publisher = this.sessionRegistry.getSessionInformation(session.getId())
128+
.flatMap(ReactiveSessionInformation::invalidate);
129+
StepVerifier.create(publisher).verifyComplete();
130+
StepVerifier.create(this.sessionRepository.findById(session.getId())).expectComplete().verify();
131+
}
132+
133+
@Test
134+
void getAllSessionsWhenSessionsExistsThenReturned() {
135+
Flux<ReactiveSessionInformation> sessions = this.sessionRegistry.getAllSessions("johndoe");
136+
StepVerifier.create(sessions)
137+
.assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe"))
138+
.assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe"))
139+
.assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe"))
140+
.verifyComplete();
141+
}
142+
143+
@Test
144+
void getAllSessionsWhenInvalidateThenSessionsRemovedFromRepository() {
145+
this.sessionRegistry.getAllSessions("johndoe").flatMap(ReactiveSessionInformation::invalidate).blockLast();
146+
StepVerifier.create(this.sessionRepository.findById(johnSession1.getId())).expectComplete().verify();
147+
StepVerifier.create(this.sessionRepository.findById(johnSession2.getId())).expectComplete().verify();
148+
StepVerifier.create(this.sessionRepository.findById(johnSession3.getId())).expectComplete().verify();
149+
}
150+
151+
static class StubIndexedSessionRepository implements ReactiveFindByIndexNameSessionRepository<MapSession> {
152+
153+
Map<String, MapSession> johnSessions = Map.of(johnSession1.getId(), johnSession1, johnSession2.getId(),
154+
johnSession2, johnSession3.getId(), johnSession3);
155+
156+
@Override
157+
public Mono<Map<String, MapSession>> findByIndexNameAndIndexValue(String indexName, String indexValue) {
158+
if ("johndoe".equals(indexValue)) {
159+
return Mono.just(this.johnSessions);
160+
}
161+
return Mono.empty();
162+
}
163+
164+
}
165+
166+
}

spring-session-docs/modules/ROOT/pages/configuration/common.adoc

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ It contains configuration examples for the following use cases:
66

77
- I need to <<changing-how-session-ids-are-generated,change the way that Session IDs are generated>>
88
- I need to <<customizing-session-cookie,customize the session cookie properties>>
9+
- I want to <<spring-session-backed-reactive-session-registry,provide a Spring Session implementation of the `ReactiveSessionRepository`>> for {spring-security-ref-docs}/reactive/authentication/concurrent-sessions-control.html[Concurrent Sessions Control]
910

1011
[[changing-how-session-ids-are-generated]]
1112
== Changing How Session IDs Are Generated
@@ -137,3 +138,29 @@ include::{samples-dir}spring-session-sample-boot-webflux-custom-cookie/src/main/
137138
<2> We customize the path of the cookie to be `/` (rather than the default of the context root).
138139
<3> We customize the `SameSite` cookie directive to be `Strict`.
139140
====
141+
142+
[[spring-session-backed-reactive-session-registry]]
143+
== Providing a Spring Session implementation of `ReactiveSessionRegistry`
144+
145+
Spring Session provides integration with Spring Security to support its reactive concurrent session control.
146+
This allows limiting the number of active sessions that a single user can have concurrently, but, unlike the default Spring Security support, this also works in a clustered environment.
147+
This is done by providing the `SpringSessionBackedReactiveSessionRegistry` implementation of Spring Security’s `ReactiveSessionRegistry` interface.
148+
149+
.Defining SpringSessionBackedReactiveSessionRegistry as a bean
150+
[tabs]
151+
======
152+
Java::
153+
+
154+
[source,java,role="primary"]
155+
----
156+
@Bean
157+
public <S extends Session> SpringSessionBackedReactiveSessionRegistry<S> sessionRegistry(
158+
ReactiveSessionRepository<S> sessionRepository,
159+
ReactiveFindByIndexNameSessionRepository<S> indexedSessionRepository) {
160+
return new SpringSessionBackedReactiveSessionRegistry<>(sessionRepository, indexedSessionRepository);
161+
}
162+
----
163+
======
164+
165+
Please, refer to {spring-security-ref-docs}/reactive/authentication/concurrent-sessions-control.html[Spring Security Concurrent Sessions Control documentation] for more ways of using the `ReactiveSessionRegistry`.
166+
You can also check a sample application https://github.com/spring-projects/spring-session/tree/main/spring-session-samples/spring-session-sample-boot-reactive-max-sessions[here].

spring-session-docs/spring-session-docs.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,19 @@ def generateAttributes() {
5353
springBootVersion = springBootVersion.contains("-")
5454
? springBootVersion.substring(0, springBootVersion.indexOf("-"))
5555
: springBootVersion
56+
def springSecurityVersion = libs.org.springframework.security.spring.security.bom.get().version
57+
springSecurityVersion = springSecurityVersion.contains("-")
58+
? springSecurityVersion.substring(0, springSecurityVersion.indexOf("-"))
59+
: springSecurityVersion
5660
def ghTag = snapshotBuild ? 'main' : project.version
5761
def docsUrl = 'https://docs.spring.io'
5862
def springBootRefDocs = "${docsUrl}/spring-boot/docs/${springBootVersion}/reference/html"
63+
def springSecurityRefDocs = "${docsUrl}/spring-security/reference/${springSecurityVersion}"
5964
return ['gh-tag':ghTag,
6065
'spring-boot-version': springBootVersion,
6166
'spring-boot-ref-docs': springBootRefDocs.toString(),
6267
'spring-session-version': project.version,
68+
'spring-security-ref-docs': springSecurityRefDocs.toString(),
6369
'docs-url': docsUrl]
6470
}
6571

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
apply plugin: 'io.spring.convention.spring-sample-boot'
2+
3+
ext['spring-security.version'] = '6.3.0-SNAPSHOT'
4+
5+
dependencies {
6+
management platform(project(":spring-session-dependencies"))
7+
implementation project(':spring-session-data-redis')
8+
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
9+
implementation 'org.springframework.boot:spring-boot-starter-security'
10+
implementation 'org.springframework.boot:spring-boot-starter-webflux'
11+
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
12+
testImplementation 'org.springframework.boot:spring-boot-starter-test'
13+
testImplementation 'io.projectreactor:reactor-test'
14+
testImplementation 'org.springframework.security:spring-security-test'
15+
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
16+
testImplementation 'org.testcontainers:junit-jupiter'
17+
testImplementation 'org.seleniumhq.selenium:selenium-java'
18+
testImplementation 'org.seleniumhq.selenium:htmlunit-driver'
19+
}
20+
21+
tasks.named('test') {
22+
useJUnitPlatform()
23+
}

0 commit comments

Comments
 (0)