From 1104b45832a16f6a9b5c4ad818e893350aec0633 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:43:19 -0700 Subject: [PATCH] Polish SessionLimit - Move to the web.authentication.session package since it is only needed by web.authentication.session elements and does not access any other web element itself. - Add Kotlin support - Add documentation Issue gh-16206 --- .../SessionManagementConfigurer.java | 2 +- .../web/session/SessionConcurrencyDsl.kt | 11 +++ .../SessionManagementConfigurerTests.java | 2 +- .../config/http/HttpHeadersConfigTests.java | 2 +- .../web/session/SessionConcurrencyDslTests.kt | 69 +++++++++++++++++-- .../authentication/session-management.adoc | 57 ++++++++++++++- ...tSessionControlAuthenticationStrategy.java | 1 - .../session/SessionLimit.java | 2 +- ...ionControlAuthenticationStrategyTests.java | 1 - .../session/SessionLimitTests.java | 2 +- 10 files changed, 137 insertions(+), 12 deletions(-) rename web/src/main/java/org/springframework/security/web/{ => authentication}/session/SessionLimit.java (96%) rename web/src/test/java/org/springframework/security/web/{ => authentication}/session/SessionLimitTests.java (96%) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java index 5ff5b00e72..fa601b9449 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java @@ -47,6 +47,7 @@ import org.springframework.security.web.authentication.session.NullAuthenticated import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.security.web.context.DelegatingSecurityContextRepository; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.NullSecurityContextRepository; @@ -59,7 +60,6 @@ import org.springframework.security.web.session.DisableEncodeUrlFilter; import org.springframework.security.web.session.ForceEagerSessionCreationFilter; import org.springframework.security.web.session.InvalidSessionStrategy; import org.springframework.security.web.session.SessionInformationExpiredStrategy; -import org.springframework.security.web.session.SessionLimit; import org.springframework.security.web.session.SessionManagementFilter; import org.springframework.security.web.session.SimpleRedirectInvalidSessionStrategy; import org.springframework.security.web.session.SimpleRedirectSessionInformationExpiredStrategy; diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt index 0d33c0702a..ce4bc54ca5 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDsl.kt @@ -19,7 +19,9 @@ package org.springframework.security.config.annotation.web.session import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer import org.springframework.security.core.session.SessionRegistry +import org.springframework.security.web.authentication.session.SessionLimit import org.springframework.security.web.session.SessionInformationExpiredStrategy +import org.springframework.util.Assert /** * A Kotlin DSL to configure the behaviour of multiple sessions using idiomatic @@ -44,12 +46,21 @@ class SessionConcurrencyDsl { var expiredSessionStrategy: SessionInformationExpiredStrategy? = null var maxSessionsPreventsLogin: Boolean? = null var sessionRegistry: SessionRegistry? = null + private var sessionLimit: SessionLimit? = null + + fun maximumSessions(max: SessionLimit) { + this.sessionLimit = max + } internal fun get(): (SessionManagementConfigurer.ConcurrencyControlConfigurer) -> Unit { + Assert.isTrue(maximumSessions == null || sessionLimit == null, "You cannot specify maximumSessions as both an Int and a SessionLimit. Please use only one.") return { sessionConcurrencyControl -> maximumSessions?.also { sessionConcurrencyControl.maximumSessions(maximumSessions!!) } + sessionLimit?.also { + sessionConcurrencyControl.maximumSessions(sessionLimit!!) + } expiredUrl?.also { sessionConcurrencyControl.expiredUrl(expiredUrl) } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java index cc3011a719..bca300ec52 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurerTests.java @@ -59,12 +59,12 @@ import org.springframework.security.web.authentication.session.ChangeSessionIdAu import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.session.ConcurrentSessionFilter; import org.springframework.security.web.session.HttpSessionDestroyedEvent; -import org.springframework.security.web.session.SessionLimit; import org.springframework.security.web.session.SessionManagementFilter; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; diff --git a/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java b/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java index 6c89be179a..2c41d1a368 100644 --- a/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java @@ -35,7 +35,7 @@ import org.springframework.beans.factory.xml.XmlBeanDefinitionStoreException; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; import org.springframework.security.core.Authentication; -import org.springframework.security.web.session.SessionLimit; +import org.springframework.security.web.authentication.session.SessionLimit; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt index 6437c54326..9117ae757a 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/session/SessionConcurrencyDslTests.kt @@ -18,18 +18,19 @@ package org.springframework.security.config.annotation.web.session import io.mockk.every import io.mockk.mockkObject -import java.util.Date import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.mock.web.MockHttpSession +import org.springframework.security.authorization.AuthorityAuthorizationManager +import org.springframework.security.authorization.AuthorizationManager import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension -import org.springframework.security.config.annotation.web.invoke import org.springframework.security.core.session.SessionInformation import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistryImpl @@ -44,6 +45,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.* /** * Tests for [SessionConcurrencyDsl] @@ -173,16 +175,75 @@ class SessionConcurrencyDslTests { open fun sessionRegistry(): SessionRegistry = SESSION_REGISTRY } + @Test + fun `session concurrency when session limit then no more sessions allowed`() { + this.spring.register(MaximumSessionsFunctionConfig::class.java, UserDetailsConfig::class.java).autowire() + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/login?error")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/")) + + this.mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password")) + .andExpect(status().isFound) + .andExpect(redirectedUrl("/")) + } + + @Configuration + @EnableWebSecurity + open class MaximumSessionsFunctionConfig { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + val isAdmin: AuthorizationManager = AuthorityAuthorizationManager.hasRole("ADMIN") + http { + sessionManagement { + sessionConcurrency { + maximumSessions { + authentication -> if (isAdmin.authorize({ authentication }, null)!!.isGranted) -1 else 1 + } + maxSessionsPreventsLogin = true + } + } + formLogin { } + } + return http.build() + } + + } + @Configuration open class UserDetailsConfig { @Bean open fun userDetailsService(): UserDetailsService { - val userDetails = User.withDefaultPasswordEncoder() + val user = User.withDefaultPasswordEncoder() .username("user") .password("password") .roles("USER") .build() - return InMemoryUserDetailsManager(userDetails) + val admin = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("ADMIN") + .build() + return InMemoryUserDetailsManager(user, admin) } } } diff --git a/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc b/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc index fe0821a31d..c6282e2351 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/session-management.adoc @@ -399,7 +399,62 @@ XML:: This will prevent a user from logging in multiple times - a second login will cause the first to be invalidated. -Using Spring Boot, you can test the above configuration scenario the following way: +You can also adjust this based on who the user is. +For example, administrators may be able to have more than one session: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public SecurityFilterChain filterChain(HttpSecurity http) { + AuthorizationManager isAdmin = AuthorityAuthorizationManager.hasRole("ADMIN"); + http + .sessionManagement(session -> session + .maximumSessions((authentication) -> isAdmin.authorize(() -> authentication, null).isGranted() ? -1 : 1) + ); + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun filterChain(http: HttpSecurity): SecurityFilterChain { + val isAdmin: AuthorizationManager<*> = AuthorityAuthorizationManager.hasRole("ADMIN") + http { + sessionManagement { + sessionConcurrency { + maximumSessions { + authentication -> if (isAdmin.authorize({ authentication }, null)!!.isGranted) -1 else 1 + } + } + } + } + return http.build() +} +---- + +XML:: ++ +[source,xml,role="secondary"] +---- + +... + + + + + + +---- +====== + +Using Spring Boot, you can test the above configurations in the following way: [tabs] ====== diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java b/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java index b8f3c9e307..51be7bd0ab 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java +++ b/web/src/main/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategy.java @@ -33,7 +33,6 @@ import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.session.ConcurrentSessionFilter; -import org.springframework.security.web.session.SessionLimit; import org.springframework.security.web.session.SessionManagementFilter; import org.springframework.util.Assert; diff --git a/web/src/main/java/org/springframework/security/web/session/SessionLimit.java b/web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java similarity index 96% rename from web/src/main/java/org/springframework/security/web/session/SessionLimit.java rename to web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java index 385c462137..362f3a7f7d 100644 --- a/web/src/main/java/org/springframework/security/web/session/SessionLimit.java +++ b/web/src/main/java/org/springframework/security/web/authentication/session/SessionLimit.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.security.web.session; +package org.springframework.security.web.authentication.session; import java.util.function.Function; diff --git a/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java b/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java index 26d4afe3ef..aa1bed6d8f 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/session/ConcurrentSessionControlAuthenticationStrategyTests.java @@ -34,7 +34,6 @@ import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; -import org.springframework.security.web.session.SessionLimit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; diff --git a/web/src/test/java/org/springframework/security/web/session/SessionLimitTests.java b/web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java similarity index 96% rename from web/src/test/java/org/springframework/security/web/session/SessionLimitTests.java rename to web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java index 01df1449d7..134f9f6e7a 100644 --- a/web/src/test/java/org/springframework/security/web/session/SessionLimitTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/session/SessionLimitTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.security.web.session; +package org.springframework.security.web.authentication.session; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest;