From 223a90ffbbfbfcfcaa98a1be9c547cd34cb4d980 Mon Sep 17 00:00:00 2001 From: Marcus Hert Da Coregio Date: Tue, 27 Feb 2024 15:11:00 -0300 Subject: [PATCH] Add SpringSessionBackedReactiveSessionRegistry Closes gh-2824 --- gradle/libs.versions.toml | 2 +- ...gSessionBackedReactiveSessionRegistry.java | 135 ++++++++++++++ ...ionBackedReactiveSessionRegistryTests.java | 166 ++++++++++++++++++ .../ROOT/pages/configuration/common.adoc | 27 +++ .../spring-session-docs.gradle | 6 + ...n-sample-boot-reactive-max-sessions.gradle | 23 +++ .../java/com/example/HelloController.java | 32 ++++ .../java/com/example/IndexController.java | 43 +++++ .../main/java/com/example/SecurityConfig.java | 91 ++++++++++ .../main/java/com/example/SessionConfig.java | 26 +++ ...gSessionSampleBootReactiveMaxSessions.java | 29 +++ .../src/main/resources/application.properties | 1 + .../src/main/resources/templates/index.html | 16 ++ .../src/test/java/com/example/BasePage.java | 41 +++++ .../src/test/java/com/example/HomePage.java | 96 ++++++++++ .../src/test/java/com/example/LoginPage.java | 66 +++++++ ...ionSampleBootReactiveMaxSessionsTests.java | 132 ++++++++++++++ .../java/com/example/TestApplication.java | 31 ++++ .../com/example/TestcontainersConfig.java | 35 ++++ 19 files changed, 997 insertions(+), 1 deletion(-) create mode 100644 spring-session-core/src/main/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistry.java create mode 100644 spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistryTests.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/spring-session-sample-boot-reactive-max-sessions.gradle create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/HelloController.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/IndexController.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SecurityConfig.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SessionConfig.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SpringSessionSampleBootReactiveMaxSessions.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/application.properties create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/templates/index.html create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/BasePage.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/HomePage.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/LoginPage.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/SpringSessionSampleBootReactiveMaxSessionsTests.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestApplication.java create mode 100644 spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestcontainersConfig.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b0d91afe..2f600907e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ org-slf4j-jcl-over-slf4j = { module = "org.slf4j:jcl-over-slf4j", version.ref = org-slf4j-log4j-over-slf4j = { module = "org.slf4j:log4j-over-slf4j", version.ref = "org-slf4j" } org-slf4j-slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "org-slf4j" } org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.0.0-M1" -org-springframework-security-spring-security-bom = "org.springframework.security:spring-security-bom:6.3.0-M2" +org-springframework-security-spring-security-bom = "org.springframework.security:spring-security-bom:6.3.0-SNAPSHOT" org-springframework-spring-framework-bom = "org.springframework:spring-framework-bom:6.1.4" 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" } diff --git a/spring-session-core/src/main/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistry.java b/spring-session-core/src/main/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistry.java new file mode 100644 index 000000000..71dac0961 --- /dev/null +++ b/spring-session-core/src/main/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistry.java @@ -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. + *

+ * 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. + *

+ * + * @param the {@link Session} type. + * @author Marcus da Coregio + * @since 3.3 + */ +public final class SpringSessionBackedReactiveSessionRegistry implements ReactiveSessionRegistry { + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + private final ReactiveSessionRepository sessionRepository; + + private final ReactiveFindByIndexNameSessionRepository indexedSessionRepository; + + public SpringSessionBackedReactiveSessionRegistry(ReactiveSessionRepository sessionRepository, + ReactiveFindByIndexNameSessionRepository indexedSessionRepository) { + Assert.notNull(sessionRepository, "sessionRepository cannot be null"); + Assert.notNull(indexedSessionRepository, "indexedSessionRepository cannot be null"); + this.sessionRepository = sessionRepository; + this.indexedSessionRepository = indexedSessionRepository; + } + + @Override + public Flux 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 saveSessionInformation(ReactiveSessionInformation information) { + return Mono.empty(); + } + + @Override + public Mono getSessionInformation(String sessionId) { + return this.sessionRepository.findById(sessionId).map(SpringSessionBackedReactiveSessionInformation::new); + } + + @Override + public Mono removeSessionInformation(String sessionId) { + return Mono.empty(); + } + + @Override + public Mono 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 invalidate() { + return super.invalidate() + .then(Mono.defer(() -> SpringSessionBackedReactiveSessionRegistry.this.sessionRepository + .deleteById(getSessionId()))); + } + + } + +} diff --git a/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistryTests.java b/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistryTests.java new file mode 100644 index 000000000..ce4df35b6 --- /dev/null +++ b/spring-session-core/src/test/java/org/springframework/session/security/SpringSessionBackedReactiveSessionRegistryTests.java @@ -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 sessionRegistry; + + ReactiveFindByIndexNameSessionRepository 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 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 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 { + + Map johnSessions = Map.of(johnSession1.getId(), johnSession1, johnSession2.getId(), + johnSession2, johnSession3.getId(), johnSession3); + + @Override + public Mono> findByIndexNameAndIndexValue(String indexName, String indexValue) { + if ("johndoe".equals(indexValue)) { + return Mono.just(this.johnSessions); + } + return Mono.empty(); + } + + } + +} diff --git a/spring-session-docs/modules/ROOT/pages/configuration/common.adoc b/spring-session-docs/modules/ROOT/pages/configuration/common.adoc index 1a842bfe9..dc2b5fea1 100644 --- a/spring-session-docs/modules/ROOT/pages/configuration/common.adoc +++ b/spring-session-docs/modules/ROOT/pages/configuration/common.adoc @@ -6,6 +6,7 @@ It contains configuration examples for the following use cases: - I need to <> - I need to <> +- I want to <> for {spring-security-ref-docs}/reactive/authentication/concurrent-sessions-control.html[Concurrent Sessions Control] [[changing-how-session-ids-are-generated]] == Changing How Session IDs Are Generated @@ -137,3 +138,29 @@ include::{samples-dir}spring-session-sample-boot-webflux-custom-cookie/src/main/ <2> We customize the path of the cookie to be `/` (rather than the default of the context root). <3> We customize the `SameSite` cookie directive to be `Strict`. ==== + +[[spring-session-backed-reactive-session-registry]] +== Providing a Spring Session implementation of `ReactiveSessionRegistry` + +Spring Session provides integration with Spring Security to support its reactive concurrent session control. +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. +This is done by providing the `SpringSessionBackedReactiveSessionRegistry` implementation of Spring Security’s `ReactiveSessionRegistry` interface. + +.Defining SpringSessionBackedReactiveSessionRegistry as a bean +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public SpringSessionBackedReactiveSessionRegistry sessionRegistry( + ReactiveSessionRepository sessionRepository, + ReactiveFindByIndexNameSessionRepository indexedSessionRepository) { + return new SpringSessionBackedReactiveSessionRegistry<>(sessionRepository, indexedSessionRepository); +} +---- +====== + +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`. +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]. diff --git a/spring-session-docs/spring-session-docs.gradle b/spring-session-docs/spring-session-docs.gradle index 03e42d15b..d72e75d9a 100644 --- a/spring-session-docs/spring-session-docs.gradle +++ b/spring-session-docs/spring-session-docs.gradle @@ -53,13 +53,19 @@ def generateAttributes() { springBootVersion = springBootVersion.contains("-") ? springBootVersion.substring(0, springBootVersion.indexOf("-")) : springBootVersion + def springSecurityVersion = libs.org.springframework.security.spring.security.bom.get().version + springSecurityVersion = springSecurityVersion.contains("-") + ? springSecurityVersion.substring(0, springSecurityVersion.indexOf("-")) + : springSecurityVersion def ghTag = snapshotBuild ? 'main' : project.version def docsUrl = 'https://docs.spring.io' def springBootRefDocs = "${docsUrl}/spring-boot/docs/${springBootVersion}/reference/html" + def springSecurityRefDocs = "${docsUrl}/spring-security/reference/${springSecurityVersion}" return ['gh-tag':ghTag, 'spring-boot-version': springBootVersion, 'spring-boot-ref-docs': springBootRefDocs.toString(), 'spring-session-version': project.version, + 'spring-security-ref-docs': springSecurityRefDocs.toString(), 'docs-url': docsUrl] } diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/spring-session-sample-boot-reactive-max-sessions.gradle b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/spring-session-sample-boot-reactive-max-sessions.gradle new file mode 100644 index 000000000..dcaa45ba2 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/spring-session-sample-boot-reactive-max-sessions.gradle @@ -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() +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/HelloController.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/HelloController.java new file mode 100644 index 000000000..72c28c526 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/HelloController.java @@ -0,0 +1,32 @@ +/* + * 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 com.example; + +import reactor.core.publisher.Mono; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class HelloController { + + @GetMapping("/hello") + Mono hello() { + return Mono.just("Hello!"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/IndexController.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/IndexController.java new file mode 100644 index 000000000..8fdd7f724 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/IndexController.java @@ -0,0 +1,43 @@ +/* + * 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 com.example; + +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +class IndexController { + + private final ReactiveFindByIndexNameSessionRepository sessionRepository; + + IndexController(ReactiveFindByIndexNameSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @GetMapping("/") + Mono index(Model model, Authentication authentication) { + return this.sessionRepository.findByPrincipalName(authentication.getName()) + .doOnNext((sessions) -> model.addAttribute("sessions", sessions.values())) + .thenReturn("index"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SecurityConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SecurityConfig.java new file mode 100644 index 000000000..c16fd647b --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SecurityConfig.java @@ -0,0 +1,91 @@ +/* + * 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 com.example; + +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.security.reactive.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.session.SessionAuthenticationException; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.PreventLoginServerMaximumSessionsExceededHandler; +import org.springframework.security.web.server.authentication.SessionLimit; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository; +import org.springframework.session.security.SpringSessionBackedReactiveSessionRegistry; + +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +public class SecurityConfig { + + @Bean + SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + // @formatter:off + return http + .authorizeExchange(exchanges -> exchanges + .matchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .anyExchange().authenticated()) + .formLogin(Customizer.withDefaults()) + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions((authentication) -> { + if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_UNLIMITED_SESSIONS"))) { + return Mono.empty(); + } + return Mono.just(1); + }) + .maximumSessionsExceededHandler(new PreventLoginServerMaximumSessionsExceededHandler()) + ) + ) + .build(); + // @formatter:on + } + + @Bean + SpringSessionBackedReactiveSessionRegistry sessionRegistry( + ReactiveSessionRepository sessionRepository, + ReactiveFindByIndexNameSessionRepository indexedSessionRepository) { + return new SpringSessionBackedReactiveSessionRegistry<>(sessionRepository, indexedSessionRepository); + } + + @Bean + MapReactiveUserDetailsService reactiveUserDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + UserDetails unlimited = User.withDefaultPasswordEncoder() + .username("unlimited") + .password("password") + .roles("USER", "UNLIMITED_SESSIONS") + .build(); + return new MapReactiveUserDetailsService(user, unlimited); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SessionConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SessionConfig.java new file mode 100644 index 000000000..637652ee2 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SessionConfig.java @@ -0,0 +1,26 @@ +/* + * 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 com.example; + +import org.springframework.context.annotation.Configuration; +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession; + +@Configuration(proxyBeanMethods = false) +@EnableRedisIndexedWebSession +public class SessionConfig { + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SpringSessionSampleBootReactiveMaxSessions.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SpringSessionSampleBootReactiveMaxSessions.java new file mode 100644 index 000000000..195d0e4e7 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/java/com/example/SpringSessionSampleBootReactiveMaxSessions.java @@ -0,0 +1,29 @@ +/* + * 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 com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringSessionSampleBootReactiveMaxSessions { + + public static void main(String[] args) { + SpringApplication.run(SpringSessionSampleBootReactiveMaxSessions.class, args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/application.properties b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/templates/index.html b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/templates/index.html new file mode 100644 index 000000000..816e88d84 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/main/resources/templates/index.html @@ -0,0 +1,16 @@ + + + Secured Content + + +

+

Secured Page

+

This page is secured using Spring Boot, Spring Session, and Spring Security.

+ + + + +
+
+ + diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/BasePage.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/BasePage.java new file mode 100644 index 000000000..775f823d8 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/BasePage.java @@ -0,0 +1,41 @@ +/* + * 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 com.example; + +import org.openqa.selenium.WebDriver; + +/** + * @author Eddú Meléndez + */ +public class BasePage { + + private WebDriver driver; + + public BasePage(WebDriver driver) { + this.driver = driver; + } + + public WebDriver getDriver() { + return this.driver; + } + + public static void get(WebDriver driver, String get) { + String baseUrl = "http://localhost"; + driver.get(baseUrl + get); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/HomePage.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/HomePage.java new file mode 100644 index 000000000..8ca42643b --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/HomePage.java @@ -0,0 +1,96 @@ +/* + * 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 com.example; + +import java.util.ArrayList; +import java.util.List; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HomePage { + + private WebDriver driver; + + @FindBy(css = "table tbody tr") + List trs; + + List attributes; + + public HomePage(WebDriver driver) { + this.driver = driver; + this.attributes = new ArrayList<>(); + } + + private static void get(WebDriver driver, int port, String get) { + String baseUrl = "http://localhost:" + port; + driver.get(baseUrl + get); + } + + public static LoginPage go(WebDriver driver, int port) { + get(driver, port, "/"); + return PageFactory.initElements(driver, LoginPage.class); + } + + public void assertAt() { + assertThat(this.driver.getTitle()).isEqualTo("Session Attributes"); + } + + public List attributes() { + List rows = new ArrayList<>(); + for (WebElement tr : this.trs) { + rows.add(new Attribute(tr)); + } + this.attributes.addAll(rows); + return this.attributes; + } + + public static class Attribute { + + @FindBy(xpath = ".//td[1]") + WebElement attributeName; + + @FindBy(xpath = ".//td[2]") + WebElement attributeValue; + + public Attribute(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + /** + * @return the attributeName + */ + public String getAttributeName() { + return this.attributeName.getText(); + } + + /** + * @return the attributeValue + */ + public String getAttributeValue() { + return this.attributeValue.getText(); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/LoginPage.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/LoginPage.java new file mode 100644 index 000000000..5fcfdd99f --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/LoginPage.java @@ -0,0 +1,66 @@ +/* + * 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 com.example; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class LoginPage extends BasePage { + + public LoginPage(WebDriver driver) { + super(driver); + } + + public void assertAt() { + assertThat(getDriver().getTitle()).isEqualTo("Please sign in"); + } + + public Form form() { + return new Form(getDriver()); + } + + public class Form { + + @FindBy(name = "username") + private WebElement username; + + @FindBy(name = "password") + private WebElement password; + + @FindBy(tagName = "button") + private WebElement button; + + public Form(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + public T login(Class page) { + this.username.sendKeys("user"); + this.password.sendKeys("password"); + this.button.click(); + return PageFactory.initElements(getDriver(), page); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/SpringSessionSampleBootReactiveMaxSessionsTests.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/SpringSessionSampleBootReactiveMaxSessionsTests.java new file mode 100644 index 000000000..c751ec74e --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/SpringSessionSampleBootReactiveMaxSessionsTests.java @@ -0,0 +1,132 @@ +/* + * 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 com.example; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +@Import(TestcontainersConfig.class) +class SpringSessionSampleBootReactiveMaxSessionsTests { + + @Autowired + WebTestClient client; + + @Autowired + ReactiveRedisConnectionFactory redisConnectionFactory; + + @BeforeEach + void setup() { + this.redisConnectionFactory.getReactiveConnection().serverCommands().flushAll().block(); + } + + @Test + void loginWhenUserAndMaximumSessionsOf1ExceededThenSecondLoginProhibited() { + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + ResponseCookie firstLoginCookie = loginReturningCookie(data); + login(data).expectStatus().isFound().expectHeader().location("/login?error"); + + performHello(firstLoginCookie).expectStatus().isOk(); + } + + @Test + void loginWhenUserAndMaximumSessionsOf1ExceededThenSecondAndThirdLoginProhibited() { + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + ResponseCookie firstLoginCookie = loginReturningCookie(data); + ResponseCookie secondLoginCookie = login(data).expectStatus() + .isFound() + .expectHeader() + .location("/login?error") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + ResponseCookie thirdLoginCookie = login(data).expectStatus() + .isFound() + .expectHeader() + .location("/login?error") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + assertThat(secondLoginCookie).isNull(); + assertThat(thirdLoginCookie).isNull(); + + performHello(firstLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + } + + @Test + void loginWhenAuthenticationHasUnlimitedSessionsThenLoginIsAlwaysAllowed() { + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "unlimited"); + data.add("password", "password"); + + ResponseCookie firstLoginCookie = loginReturningCookie(data); + ResponseCookie secondLoginCookie = loginReturningCookie(data); + ResponseCookie thirdLoginCookie = loginReturningCookie(data); + ResponseCookie fourthLoginCookie = loginReturningCookie(data); + ResponseCookie fifthLoginCookie = loginReturningCookie(data); + + performHello(firstLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + performHello(secondLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + performHello(thirdLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + performHello(fourthLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + performHello(fifthLoginCookie).expectStatus().isOk().expectBody(String.class).isEqualTo("Hello!"); + } + + private WebTestClient.ResponseSpec performHello(ResponseCookie cookie) { + return this.client.get().uri("/hello").cookie(cookie.getName(), cookie.getValue()).exchange(); + } + + private ResponseCookie loginReturningCookie(MultiValueMap data) { + return login(data).expectCookie() + .exists("SESSION") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + } + + private WebTestClient.ResponseSpec login(MultiValueMap data) { + return this.client.mutateWith(csrf()) + .post() + .uri("/login") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromFormData(data)) + .exchange(); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestApplication.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestApplication.java new file mode 100644 index 000000000..62ac4b3a0 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestApplication.java @@ -0,0 +1,31 @@ +/* + * 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 com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.TestConfiguration; + +@TestConfiguration(proxyBeanMethods = false) +public class TestApplication { + + public static void main(String[] args) { + SpringApplication.from(SpringSessionSampleBootReactiveMaxSessions::main) + .with(TestcontainersConfig.class) + .run(args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestcontainersConfig.java b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestcontainersConfig.java new file mode 100644 index 000000000..e91933f51 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-reactive-max-sessions/src/test/java/com/example/TestcontainersConfig.java @@ -0,0 +1,35 @@ +/* + * 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 com.example; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; + +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfig { + + @Bean + @ServiceConnection(name = "redis") + GenericContainer redisContainer() { + return new GenericContainer<>(DockerImageName.parse("redis:6.2.6")).withExposedPorts(6379); + } + +}