-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add SpringSessionBackedReactiveSessionRegistry
Closes gh-2824
- Loading branch information
1 parent
203a100
commit 223a90f
Showing
19 changed files
with
997 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
...java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistry.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
/* | ||
* Copyright 2014-2024 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.security; | ||
|
||
import reactor.core.publisher.Flux; | ||
import reactor.core.publisher.Mono; | ||
|
||
import org.springframework.security.authentication.AbstractAuthenticationToken; | ||
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.session.ReactiveSessionInformation; | ||
import org.springframework.security.core.session.ReactiveSessionRegistry; | ||
import org.springframework.session.ReactiveFindByIndexNameSessionRepository; | ||
import org.springframework.session.ReactiveSessionRepository; | ||
import org.springframework.session.Session; | ||
import org.springframework.util.Assert; | ||
|
||
/** | ||
* A {@link ReactiveSessionRegistry} that retrieves session information from Spring | ||
* Session, rather than maintaining it itself. This allows concurrent session management | ||
* with Spring Security in a clustered environment. | ||
* <p> | ||
* Relies on being able to derive the same String-based representation of the principal | ||
* given to {@link #getAllSessions(Object)} as used by Spring Session in order to look up | ||
* the user's sessions. | ||
* <p> | ||
* | ||
* @param <S> the {@link Session} type. | ||
* @author Marcus da Coregio | ||
* @since 3.3 | ||
*/ | ||
public final class SpringSessionBackedReactiveSessionRegistry<S extends Session> implements ReactiveSessionRegistry { | ||
|
||
private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; | ||
|
||
private final ReactiveSessionRepository<S> sessionRepository; | ||
|
||
private final ReactiveFindByIndexNameSessionRepository<S> indexedSessionRepository; | ||
|
||
public SpringSessionBackedReactiveSessionRegistry(ReactiveSessionRepository<S> sessionRepository, | ||
ReactiveFindByIndexNameSessionRepository<S> indexedSessionRepository) { | ||
Assert.notNull(sessionRepository, "sessionRepository cannot be null"); | ||
Assert.notNull(indexedSessionRepository, "indexedSessionRepository cannot be null"); | ||
this.sessionRepository = sessionRepository; | ||
this.indexedSessionRepository = indexedSessionRepository; | ||
} | ||
|
||
@Override | ||
public Flux<ReactiveSessionInformation> getAllSessions(Object principal) { | ||
Authentication authenticationToken = getAuthenticationToken(principal); | ||
return this.indexedSessionRepository.findByPrincipalName(authenticationToken.getName()) | ||
.flatMapMany((sessionMap) -> Flux.fromIterable(sessionMap.entrySet())) | ||
.map((entry) -> new SpringSessionBackedReactiveSessionInformation(entry.getValue())); | ||
} | ||
|
||
@Override | ||
public Mono<Void> saveSessionInformation(ReactiveSessionInformation information) { | ||
return Mono.empty(); | ||
} | ||
|
||
@Override | ||
public Mono<ReactiveSessionInformation> getSessionInformation(String sessionId) { | ||
return this.sessionRepository.findById(sessionId).map(SpringSessionBackedReactiveSessionInformation::new); | ||
} | ||
|
||
@Override | ||
public Mono<ReactiveSessionInformation> removeSessionInformation(String sessionId) { | ||
return Mono.empty(); | ||
} | ||
|
||
@Override | ||
public Mono<ReactiveSessionInformation> updateLastAccessTime(String sessionId) { | ||
return Mono.empty(); | ||
} | ||
|
||
private static Authentication getAuthenticationToken(Object principal) { | ||
return new AbstractAuthenticationToken(AuthorityUtils.NO_AUTHORITIES) { | ||
|
||
@Override | ||
public Object getCredentials() { | ||
return null; | ||
} | ||
|
||
@Override | ||
public Object getPrincipal() { | ||
return principal; | ||
} | ||
|
||
}; | ||
} | ||
|
||
class SpringSessionBackedReactiveSessionInformation extends ReactiveSessionInformation { | ||
|
||
SpringSessionBackedReactiveSessionInformation(S session) { | ||
super(resolvePrincipalName(session), session.getId(), session.getLastAccessedTime()); | ||
} | ||
|
||
private static String resolvePrincipalName(Session session) { | ||
String principalName = session | ||
.getAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); | ||
if (principalName != null) { | ||
return principalName; | ||
} | ||
SecurityContext securityContext = session.getAttribute(SPRING_SECURITY_CONTEXT); | ||
if (securityContext != null && securityContext.getAuthentication() != null) { | ||
return securityContext.getAuthentication().getName(); | ||
} | ||
return ""; | ||
} | ||
|
||
@Override | ||
public Mono<Void> invalidate() { | ||
return super.invalidate() | ||
.then(Mono.defer(() -> SpringSessionBackedReactiveSessionRegistry.this.sessionRepository | ||
.deleteById(getSessionId()))); | ||
} | ||
|
||
} | ||
|
||
} |
166 changes: 166 additions & 0 deletions
166
...org/springframework/session/security/SpringSessionBackedReactiveSessionRegistryTests.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
/* | ||
* Copyright 2014-2024 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.security; | ||
|
||
import java.util.Map; | ||
import java.util.concurrent.ConcurrentHashMap; | ||
|
||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.Test; | ||
import reactor.core.publisher.Flux; | ||
import reactor.core.publisher.Mono; | ||
import reactor.test.StepVerifier; | ||
|
||
import org.springframework.security.authentication.TestingAuthenticationToken; | ||
import org.springframework.security.core.context.SecurityContextImpl; | ||
import org.springframework.security.core.session.ReactiveSessionInformation; | ||
import org.springframework.session.MapSession; | ||
import org.springframework.session.ReactiveFindByIndexNameSessionRepository; | ||
import org.springframework.session.ReactiveMapSessionRepository; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
|
||
class SpringSessionBackedReactiveSessionRegistryTests { | ||
|
||
static MapSession johnSession1 = new MapSession(); | ||
static MapSession johnSession2 = new MapSession(); | ||
static MapSession johnSession3 = new MapSession(); | ||
|
||
SpringSessionBackedReactiveSessionRegistry<MapSession> sessionRegistry; | ||
|
||
ReactiveFindByIndexNameSessionRepository<MapSession> indexedSessionRepository = new StubIndexedSessionRepository(); | ||
|
||
ReactiveMapSessionRepository sessionRepository = new ReactiveMapSessionRepository(new ConcurrentHashMap<>()); | ||
|
||
static { | ||
johnSession1.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe"); | ||
johnSession2.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe"); | ||
johnSession3.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe"); | ||
} | ||
|
||
@BeforeEach | ||
void setup() { | ||
this.sessionRegistry = new SpringSessionBackedReactiveSessionRegistry<>(this.sessionRepository, | ||
this.indexedSessionRepository); | ||
this.sessionRepository.save(johnSession1).block(); | ||
this.sessionRepository.save(johnSession2).block(); | ||
this.sessionRepository.save(johnSession3).block(); | ||
} | ||
|
||
@Test | ||
void saveSessionInformationThenDoNothing() { | ||
StepVerifier.create(this.sessionRegistry.saveSessionInformation(null)).expectComplete().verify(); | ||
} | ||
|
||
@Test | ||
void removeSessionInformationThenDoNothing() { | ||
StepVerifier.create(this.sessionRegistry.removeSessionInformation(null)).expectComplete().verify(); | ||
} | ||
|
||
@Test | ||
void updateLastAccessTimeThenDoNothing() { | ||
StepVerifier.create(this.sessionRegistry.updateLastAccessTime(null)).expectComplete().verify(); | ||
} | ||
|
||
@Test | ||
void getSessionInformationWhenPrincipalIndexNamePresentThenPrincipalResolved() { | ||
MapSession session = this.sessionRepository.createSession().block(); | ||
session.setAttribute(ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, "johndoe"); | ||
this.sessionRepository.save(session).block(); | ||
StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId())) | ||
.assertNext((sessionInformation) -> { | ||
assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId()); | ||
assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime()); | ||
assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe"); | ||
}) | ||
.verifyComplete(); | ||
} | ||
|
||
@Test | ||
void getSessionInformationWhenSecurityContextAttributePresentThenPrincipalResolved() { | ||
MapSession session = this.sessionRepository.createSession().block(); | ||
TestingAuthenticationToken authentication = new TestingAuthenticationToken("johndoe", "n/a"); | ||
SecurityContextImpl securityContext = new SecurityContextImpl(); | ||
securityContext.setAuthentication(authentication); | ||
session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext); | ||
this.sessionRepository.save(session).block(); | ||
StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId())) | ||
.assertNext((sessionInformation) -> { | ||
assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId()); | ||
assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime()); | ||
assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe"); | ||
}) | ||
.verifyComplete(); | ||
} | ||
|
||
@Test | ||
void getSessionInformationWhenNoResolvablePrincipalThenPrincipalBlank() { | ||
MapSession session = this.sessionRepository.createSession().block(); | ||
this.sessionRepository.save(session).block(); | ||
StepVerifier.create(this.sessionRegistry.getSessionInformation(session.getId())) | ||
.assertNext((sessionInformation) -> { | ||
assertThat(sessionInformation.getSessionId()).isEqualTo(session.getId()); | ||
assertThat(sessionInformation.getLastAccessTime()).isEqualTo(session.getLastAccessedTime()); | ||
assertThat(sessionInformation.getPrincipal()).isEqualTo(""); | ||
}) | ||
.verifyComplete(); | ||
} | ||
|
||
@Test | ||
void getSessionInformationWhenInvalidateThenRemovedFromSessionRepository() { | ||
MapSession session = this.sessionRepository.createSession().block(); | ||
this.sessionRepository.save(session).block(); | ||
Mono<Void> publisher = this.sessionRegistry.getSessionInformation(session.getId()) | ||
.flatMap(ReactiveSessionInformation::invalidate); | ||
StepVerifier.create(publisher).verifyComplete(); | ||
StepVerifier.create(this.sessionRepository.findById(session.getId())).expectComplete().verify(); | ||
} | ||
|
||
@Test | ||
void getAllSessionsWhenSessionsExistsThenReturned() { | ||
Flux<ReactiveSessionInformation> sessions = this.sessionRegistry.getAllSessions("johndoe"); | ||
StepVerifier.create(sessions) | ||
.assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe")) | ||
.assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe")) | ||
.assertNext((sessionInformation) -> assertThat(sessionInformation.getPrincipal()).isEqualTo("johndoe")) | ||
.verifyComplete(); | ||
} | ||
|
||
@Test | ||
void getAllSessionsWhenInvalidateThenSessionsRemovedFromRepository() { | ||
this.sessionRegistry.getAllSessions("johndoe").flatMap(ReactiveSessionInformation::invalidate).blockLast(); | ||
StepVerifier.create(this.sessionRepository.findById(johnSession1.getId())).expectComplete().verify(); | ||
StepVerifier.create(this.sessionRepository.findById(johnSession2.getId())).expectComplete().verify(); | ||
StepVerifier.create(this.sessionRepository.findById(johnSession3.getId())).expectComplete().verify(); | ||
} | ||
|
||
static class StubIndexedSessionRepository implements ReactiveFindByIndexNameSessionRepository<MapSession> { | ||
|
||
Map<String, MapSession> johnSessions = Map.of(johnSession1.getId(), johnSession1, johnSession2.getId(), | ||
johnSession2, johnSession3.getId(), johnSession3); | ||
|
||
@Override | ||
public Mono<Map<String, MapSession>> findByIndexNameAndIndexValue(String indexName, String indexValue) { | ||
if ("johndoe".equals(indexValue)) { | ||
return Mono.just(this.johnSessions); | ||
} | ||
return Mono.empty(); | ||
} | ||
|
||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
23 changes: 23 additions & 0 deletions
23
...sample-boot-reactive-max-sessions/spring-session-sample-boot-reactive-max-sessions.gradle
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
apply plugin: 'io.spring.convention.spring-sample-boot' | ||
|
||
ext['spring-security.version'] = '6.3.0-SNAPSHOT' | ||
|
||
dependencies { | ||
management platform(project(":spring-session-dependencies")) | ||
implementation project(':spring-session-data-redis') | ||
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' | ||
implementation 'org.springframework.boot:spring-boot-starter-security' | ||
implementation 'org.springframework.boot:spring-boot-starter-webflux' | ||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' | ||
testImplementation 'org.springframework.boot:spring-boot-starter-test' | ||
testImplementation 'io.projectreactor:reactor-test' | ||
testImplementation 'org.springframework.security:spring-security-test' | ||
testImplementation 'org.springframework.boot:spring-boot-testcontainers' | ||
testImplementation 'org.testcontainers:junit-jupiter' | ||
testImplementation 'org.seleniumhq.selenium:selenium-java' | ||
testImplementation 'org.seleniumhq.selenium:htmlunit-driver' | ||
} | ||
|
||
tasks.named('test') { | ||
useJUnitPlatform() | ||
} |
Oops, something went wrong.