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 fc4a2a3880..5ff5b00e72 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 @@ -59,6 +59,7 @@ 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; @@ -123,7 +124,7 @@ public final class SessionManagementConfigurer> private SessionRegistry sessionRegistry; - private Integer maximumSessions; + private SessionLimit sessionLimit; private String expiredUrl; @@ -329,7 +330,7 @@ public final class SessionManagementConfigurer> * @return the {@link SessionManagementConfigurer} for further customizations */ public ConcurrencyControlConfigurer maximumSessions(int maximumSessions) { - this.maximumSessions = maximumSessions; + this.sessionLimit = SessionLimit.of(maximumSessions); this.propertiesThatRequireImplicitAuthentication.add("maximumSessions = " + maximumSessions); return new ConcurrencyControlConfigurer(); } @@ -570,7 +571,7 @@ public final class SessionManagementConfigurer> SessionRegistry sessionRegistry = getSessionRegistry(http); ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlStrategy = new ConcurrentSessionControlAuthenticationStrategy( sessionRegistry); - concurrentSessionControlStrategy.setMaximumSessions(this.maximumSessions); + concurrentSessionControlStrategy.setMaximumSessions(this.sessionLimit); concurrentSessionControlStrategy.setExceptionIfMaximumExceeded(this.maxSessionsPreventsLogin); concurrentSessionControlStrategy = postProcess(concurrentSessionControlStrategy); RegisterSessionAuthenticationStrategy registerSessionStrategy = new RegisterSessionAuthenticationStrategy( @@ -614,7 +615,7 @@ public final class SessionManagementConfigurer> * @return */ private boolean isConcurrentSessionControlEnabled() { - return this.maximumSessions != null; + return this.sessionLimit != null; } /** @@ -706,7 +707,19 @@ public final class SessionManagementConfigurer> * @return the {@link ConcurrencyControlConfigurer} for further customizations */ public ConcurrencyControlConfigurer maximumSessions(int maximumSessions) { - SessionManagementConfigurer.this.maximumSessions = maximumSessions; + SessionManagementConfigurer.this.sessionLimit = SessionLimit.of(maximumSessions); + return this; + } + + /** + * Determines the behaviour when a session limit is detected. + * @param sessionLimit the {@link SessionLimit} to check the maximum number of + * sessions for a user + * @return the {@link ConcurrencyControlConfigurer} for further customizations + * @since 6.5 + */ + public ConcurrencyControlConfigurer maximumSessions(SessionLimit sessionLimit) { + SessionManagementConfigurer.this.sessionLimit = sessionLimit; return this; } diff --git a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java index 53635b5aa0..db915da867 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java @@ -122,6 +122,10 @@ class HttpConfigurationBuilder { private static final String ATT_SESSION_AUTH_STRATEGY_REF = "session-authentication-strategy-ref"; + private static final String ATT_MAX_SESSIONS_REF = "max-sessions-ref"; + + private static final String ATT_MAX_SESSIONS = "max-sessions"; + private static final String ATT_SESSION_AUTH_ERROR_URL = "session-authentication-error-url"; private static final String ATT_SECURITY_CONTEXT_HOLDER_STRATEGY = "security-context-holder-strategy-ref"; @@ -485,10 +489,16 @@ class HttpConfigurationBuilder { concurrentSessionStrategy.addConstructorArgValue(this.sessionRegistryRef); String maxSessions = this.pc.getReaderContext() .getEnvironment() - .resolvePlaceholders(sessionCtrlElt.getAttribute("max-sessions")); + .resolvePlaceholders(sessionCtrlElt.getAttribute(ATT_MAX_SESSIONS)); if (StringUtils.hasText(maxSessions)) { concurrentSessionStrategy.addPropertyValue("maximumSessions", maxSessions); } + String maxSessionsRef = this.pc.getReaderContext() + .getEnvironment() + .resolvePlaceholders(sessionCtrlElt.getAttribute(ATT_MAX_SESSIONS_REF)); + if (StringUtils.hasText(maxSessionsRef)) { + concurrentSessionStrategy.addPropertyReference("maximumSessions", maxSessionsRef); + } String exceptionIfMaximumExceeded = sessionCtrlElt.getAttribute("error-if-maximum-exceeded"); if (StringUtils.hasText(exceptionIfMaximumExceeded)) { concurrentSessionStrategy.addPropertyValue("exceptionIfMaximumExceeded", exceptionIfMaximumExceeded); @@ -591,6 +601,12 @@ class HttpConfigurationBuilder { .error("Cannot use 'expired-url' attribute and 'expired-session-strategy-ref'" + " attribute together.", source); } + String maxSessions = element.getAttribute(ATT_MAX_SESSIONS); + String maxSessionsRef = element.getAttribute(ATT_MAX_SESSIONS_REF); + if (StringUtils.hasText(maxSessions) && StringUtils.hasText(maxSessionsRef)) { + this.pc.getReaderContext() + .error("Cannot use 'max-sessions' attribute and 'max-sessions-ref' attribute together.", source); + } if (StringUtils.hasText(expiryUrl)) { BeanDefinitionBuilder expiredSessionBldr = BeanDefinitionBuilder .rootBeanDefinition(SimpleRedirectSessionInformationExpiredStrategy.class); diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc index 9b2469aa87..9dcb730571 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc @@ -934,6 +934,9 @@ concurrency-control = concurrency-control.attlist &= ## The maximum number of sessions a single authenticated user can have open at the same time. Defaults to "1". A negative value denotes unlimited sessions. attribute max-sessions {xsd:token}? +concurrency-control.attlist &= + ## Allows injection of the SessionLimit instance used by the ConcurrentSessionControlAuthenticationStrategy + attribute max-sessions-ref {xsd:token}? concurrency-control.attlist &= ## The URL a user will be redirected to if they attempt to use a session which has been "expired" because they have logged in again. attribute expired-url {xsd:token}? diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd index e46438d80d..03a00f3665 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd @@ -2688,6 +2688,13 @@ + + + Allows injection of the SessionLimit instance used by the + ConcurrentSessionControlAuthenticationStrategy + + + The URL a user will be redirected to if they attempt to use a session which has been 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 fbe52459a4..cc3011a719 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-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. @@ -64,6 +64,7 @@ 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; @@ -249,6 +250,82 @@ public class SessionManagementConfigurerTests { // @formatter:on } + @Test + public void loginWhenAdminUserLoggedInAndSessionLimitIsConfiguredThenLoginSuccessfully() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + } + + @Test + public void loginWhenAdminUserLoggedInAndSessionLimitIsConfiguredThenLoginPrevented() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void loginWhenUserLoggedInAndSessionLimitIsConfiguredThenLoginPrevented() throws Exception { + this.spring.register(ConcurrencyControlWithSessionLimitConfig.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + @Test public void requestWhenSessionCreationPolicyStateLessInLambdaThenNoSessionCreated() throws Exception { this.spring.register(SessionCreationPolicyStateLessInLambdaConfig.class).autowire(); @@ -625,6 +702,42 @@ public class SessionManagementConfigurerTests { } + @Configuration + @EnableWebSecurity + static class ConcurrencyControlWithSessionLimitConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http, SessionLimit sessionLimit) throws Exception { + // @formatter:off + http + .formLogin(withDefaults()) + .sessionManagement((sessionManagement) -> sessionManagement + .sessionConcurrency((sessionConcurrency) -> sessionConcurrency + .maximumSessions(sessionLimit) + .maxSessionsPreventsLogin(true) + ) + ); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.admin(), PasswordEncodedUser.user()); + } + + @Bean + SessionLimit SessionLimit() { + return (authentication) -> { + if ("admin".equals(authentication.getName())) { + return 2; + } + return 1; + }; + } + + } + @Configuration @EnableWebSecurity static class SessionCreationPolicyStateLessInLambdaConfig { 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 c66933de16..6c89be179a 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-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. @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Set; import com.google.common.collect.ImmutableMap; +import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,14 +34,21 @@ import org.springframework.beans.factory.parsing.BeanDefinitionParsingException; 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.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -49,6 +57,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * @author Josh Cummings * @author Rafiullah Hamedy * @author Marcus Da Coregio + * @author Claudenir Freitas */ @ExtendWith(SpringTestContextExtension.class) public class HttpHeadersConfigTests { @@ -782,6 +791,120 @@ public class HttpHeadersConfigTests { // @formatter:on } + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionIsOne() throws Exception { + System.setProperty("security.session-management.concurrency-control.max-sessions", "1"); + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessions")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + // @formatter:on + assertThat(firstSession).isNotNull(); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionIsUnlimited() throws Exception { + System.setProperty("security.session-management.concurrency-control.max-sessions", "-1"); + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessions")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionRefIsOneForNonAdminUsers() throws Exception { + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessionsRef")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "user") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + // @formatter:on + assertThat(firstSession).isNotNull(); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlMaxSessionRefIsTwoForAdminUsers() throws Exception { + this.spring.configLocations(this.xml("DefaultsSessionManagementConcurrencyControlMaxSessionsRef")).autowire(); + // @formatter:off + MockHttpServletRequestBuilder requestBuilder = post("/login") + .with(csrf()) + .param("username", "admin") + .param("password", "password"); + HttpSession firstSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(firstSession).isNotNull(); + HttpSession secondSession = this.mvc.perform(requestBuilder) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")) + .andReturn() + .getRequest() + .getSession(false); + assertThat(secondSession).isNotNull(); + // @formatter:on + assertThat(firstSession.getId()).isNotEqualTo(secondSession.getId()); + // @formatter:off + this.mvc.perform(requestBuilder) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error")); + // @formatter:on + } + + @Test + public void requestWhenSessionManagementConcurrencyControlWithInvalidMaxSessionConfig() { + assertThatExceptionOfType(BeanDefinitionParsingException.class) + .isThrownBy(() -> this.spring + .configLocations(this.xml("DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig")) + .autowire()) + .withMessageContaining("Cannot use 'max-sessions' attribute and 'max-sessions-ref' attribute together."); + } + private static ResultMatcher includesDefaults() { return includes(defaultHeaders); } @@ -832,4 +955,16 @@ public class HttpHeadersConfigTests { } + public static class CustomSessionLimit implements SessionLimit { + + @Override + public Integer apply(Authentication authentication) { + if ("admin".equals(authentication.getName())) { + return 2; + } + return 1; + } + + } + } diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml new file mode 100644 index 0000000000..7e8c3d12a3 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessions.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml new file mode 100644 index 0000000000..98215ca86c --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlMaxSessionsRef.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml new file mode 100644 index 0000000000..7bf56c9a3a --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-DefaultsSessionManagementConcurrencyControlWithInvalidMaxSessionsConfig.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index 59e48e0986..d49c2f12db 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -2168,6 +2168,9 @@ Allows injection of the ExpiredSessionStrategy instance used by the ConcurrentSe Maps to the `maximumSessions` property of `ConcurrentSessionControlAuthenticationStrategy`. Specify `-1` as the value to support unlimited sessions. +[[nsa-concurrency-control-max-sessions-ref]] +* **max-sessions-ref** +Allows injection of the SessionLimit instance used by the ConcurrentSessionControlAuthenticationStrategy [[nsa-concurrency-control-session-registry-alias]] * **session-registry-alias** 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 8d528f5621..b8f3c9e307 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,6 +33,7 @@ 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; @@ -76,7 +77,7 @@ public class ConcurrentSessionControlAuthenticationStrategy private boolean exceptionIfMaximumExceeded = false; - private int maximumSessions = 1; + private SessionLimit sessionLimit = SessionLimit.of(1); /** * @param sessionRegistry the session registry which should be updated when the @@ -130,7 +131,7 @@ public class ConcurrentSessionControlAuthenticationStrategy * @return either -1 meaning unlimited, or a positive integer to limit (never zero) */ protected int getMaximumSessionsForThisUser(Authentication authentication) { - return this.maximumSessions; + return this.sessionLimit.apply(authentication); } /** @@ -172,15 +173,24 @@ public class ConcurrentSessionControlAuthenticationStrategy } /** - * Sets the maxSessions property. The default value is 1. Use -1 for + * Sets the sessionLimit property. The default value is 1. Use -1 for * unlimited sessions. * @param maximumSessions the maximum number of permitted sessions a user can have * open simultaneously. */ public void setMaximumSessions(int maximumSessions) { - Assert.isTrue(maximumSessions != 0, - "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); - this.maximumSessions = maximumSessions; + this.sessionLimit = SessionLimit.of(maximumSessions); + } + + /** + * Sets the sessionLimit property. The default value is 1. Use -1 for + * unlimited sessions. + * @param sessionLimit the session limit strategy + * @since 6.5 + */ + public void setMaximumSessions(SessionLimit sessionLimit) { + Assert.notNull(sessionLimit, "sessionLimit cannot be null"); + this.sessionLimit = sessionLimit; } /** diff --git a/web/src/main/java/org/springframework/security/web/session/SessionLimit.java b/web/src/main/java/org/springframework/security/web/session/SessionLimit.java new file mode 100644 index 0000000000..385c462137 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/session/SessionLimit.java @@ -0,0 +1,49 @@ +/* + * Copyright 2015-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.security.web.session; + +import java.util.function.Function; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * Represents the maximum number of sessions allowed. Use {@link #UNLIMITED} to indicate + * that there is no limit. + * + * @author Claudenir Freitas + * @since 6.5 + */ +public interface SessionLimit extends Function { + + /** + * Represents unlimited sessions. + */ + SessionLimit UNLIMITED = (authentication) -> -1; + + /** + * Creates a {@link SessionLimit} that always returns the given value for any user + * @param maxSessions the maximum number of sessions allowed + * @return a {@link SessionLimit} instance that returns the given value. + */ + static SessionLimit of(int maxSessions) { + Assert.isTrue(maxSessions != 0, + "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); + return (authentication) -> maxSessions; + } + +} 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 ffe51cc2a0..26d4afe3ef 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-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. @@ -34,6 +34,7 @@ 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; @@ -41,9 +42,11 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; /** * @author Rob Winch + * @author Claudenir Freitas * */ @ExtendWith(MockitoExtension.class) @@ -144,6 +147,86 @@ public class ConcurrentSessionControlAuthenticationStrategyTests { assertThat(this.sessionInformation.isExpired()).isFalse(); } + @Test + public void setMaximumSessionsWithNullValue() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> this.strategy.setMaximumSessions(null)) + .withMessage("sessionLimit cannot be null"); + } + + @Test + public void noRegisteredSessionUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())).willReturn(Collections.emptyList()); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + // no exception + } + + @Test + public void maxSessionsSameSessionIdUsingSessionLimit() { + MockHttpSession session = new MockHttpSession(new MockServletContext(), this.sessionInformation.getSessionId()); + this.request.setSession(session); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + // no exception + } + + @Test + public void maxSessionsWithExceptionUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.setExceptionIfMaximumExceeded(true); + assertThatExceptionOfType(SessionAuthenticationException.class) + .isThrownBy(() -> this.strategy.onAuthentication(this.authentication, this.request, this.response)); + } + + @Test + public void maxSessionsExpireExistingUserUsingSessionLimit() { + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Collections.singletonList(this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(1)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(this.sessionInformation.isExpired()).isTrue(); + } + + @Test + public void maxSessionsExpireLeastRecentExistingUserUsingSessionLimit() { + SessionInformation moreRecentSessionInfo = new SessionInformation(this.authentication.getPrincipal(), "unique", + new Date(1374766999999L)); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Arrays.asList(moreRecentSessionInfo, this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(2)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(this.sessionInformation.isExpired()).isTrue(); + } + + @Test + public void onAuthenticationWhenMaxSessionsExceededByTwoThenTwoSessionsExpiredUsingSessionLimit() { + SessionInformation oldestSessionInfo = new SessionInformation(this.authentication.getPrincipal(), "unique1", + new Date(1374766134214L)); + SessionInformation secondOldestSessionInfo = new SessionInformation(this.authentication.getPrincipal(), + "unique2", new Date(1374766134215L)); + given(this.sessionRegistry.getAllSessions(any(), anyBoolean())) + .willReturn(Arrays.asList(oldestSessionInfo, secondOldestSessionInfo, this.sessionInformation)); + this.strategy.setMaximumSessions(SessionLimit.of(2)); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + assertThat(oldestSessionInfo.isExpired()).isTrue(); + assertThat(secondOldestSessionInfo.isExpired()).isTrue(); + assertThat(this.sessionInformation.isExpired()).isFalse(); + } + + @Test + public void onAuthenticationWhenSessionLimitIsUnlimited() { + this.strategy.setMaximumSessions(SessionLimit.UNLIMITED); + this.strategy.onAuthentication(this.authentication, this.request, this.response); + verifyNoInteractions(this.sessionRegistry); + } + @Test public void setMessageSourceNull() { assertThatIllegalArgumentException().isThrownBy(() -> this.strategy.setMessageSource(null)); diff --git a/web/src/test/java/org/springframework/security/web/session/SessionLimitTests.java b/web/src/test/java/org/springframework/security/web/session/SessionLimitTests.java new file mode 100644 index 0000000000..01df1449d7 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/session/SessionLimitTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-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.security.web.session; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; + +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * @author Claudenir Freitas + * @since 6.5 + */ +class SessionLimitTests { + + private final Authentication authentication = Mockito.mock(Authentication.class); + + @Test + void testUnlimitedInstance() { + SessionLimit sessionLimit = SessionLimit.UNLIMITED; + int result = sessionLimit.apply(this.authentication); + assertThat(result).isEqualTo(-1); + } + + @ParameterizedTest + @ValueSource(ints = { -1, 1, 2, 3 }) + void testInstanceWithValidMaxSessions(int maxSessions) { + SessionLimit sessionLimit = SessionLimit.of(maxSessions); + int result = sessionLimit.apply(this.authentication); + assertThat(result).isEqualTo(maxSessions); + } + + @Test + void testInstanceWithInvalidMaxSessions() { + assertThatIllegalArgumentException().isThrownBy(() -> SessionLimit.of(0)) + .withMessage( + "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); + } + +}