From 57ab15127af082a75bdc4a7a35d27469d7417756 Mon Sep 17 00:00:00 2001 From: Marcus Da Coregio Date: Tue, 25 Jul 2023 15:31:30 -0300 Subject: [PATCH] Add Max Sessions on WebFlux Closes gh-6192 --- .../config/web/server/ServerHttpSecurity.java | 411 +++++++++++- .../web/server/ServerHttpSecurityDsl.kt | 30 + .../web/server/ServerSessionConcurrencyDsl.kt | 48 ++ .../web/server/ServerSessionManagementDsl.kt | 64 ++ .../web/server/ServerHttpSecurityTests.java | 10 +- .../server/SessionManagementSpecTests.java | 624 ++++++++++++++++++ .../server/ServerSessionManagementDslTests.kt | 283 ++++++++ .../InMemoryReactiveSessionRegistry.java | 95 +++ .../session/ReactiveSessionInformation.java | 83 +++ .../core/session/ReactiveSessionRegistry.java | 67 ++ docs/modules/ROOT/nav.adoc | 2 + .../concurrent-sessions-control.adoc | 465 +++++++++++++ docs/modules/ROOT/pages/whats-new.adoc | 4 + .../SessionAuthenticationException.java | 11 +- ...rolServerAuthenticationSuccessHandler.java | 111 ++++ ...ingServerAuthenticationSuccessHandler.java | 12 +- ...dServerMaximumSessionsExceededHandler.java | 61 ++ .../MaximumSessionsContext.java | 51 ++ ...nServerMaximumSessionsExceededHandler.java | 39 ++ ...ionServerAuthenticationSuccessHandler.java | 52 ++ .../ServerMaximumSessionsExceededHandler.java | 38 ++ .../server/authentication/SessionLimit.java | 50 ++ ...ebSessionStoreReactiveSessionRegistry.java | 100 +++ ...rverAuthenticationSuccessHandlerTests.java | 16 +- ...rverAuthenticationSuccessHandlerTests.java | 171 +++++ .../InMemoryReactiveSessionRegistryTests.java | 107 +++ ...erMaximumSessionsExceededHandlerTests.java | 91 +++ ...erMaximumSessionsExceededHandlerTests.java | 47 ++ ...rverAuthenticationSuccessHandlerTests.java | 84 +++ ...sionStoreReactiveSessionRegistryTests.java | 138 ++++ 30 files changed, 3342 insertions(+), 23 deletions(-) create mode 100644 config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionConcurrencyDsl.kt create mode 100644 config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDsl.kt create mode 100644 config/src/test/java/org/springframework/security/config/web/server/SessionManagementSpecTests.java create mode 100644 config/src/test/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDslTests.kt create mode 100644 core/src/main/java/org/springframework/security/core/session/InMemoryReactiveSessionRegistry.java create mode 100644 core/src/main/java/org/springframework/security/core/session/ReactiveSessionInformation.java create mode 100644 core/src/main/java/org/springframework/security/core/session/ReactiveSessionRegistry.java create mode 100644 docs/modules/ROOT/pages/reactive/authentication/concurrent-sessions-control.adoc create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/ConcurrentSessionControlServerAuthenticationSuccessHandler.java create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/InvalidateLeastUsedServerMaximumSessionsExceededHandler.java create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/MaximumSessionsContext.java create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/PreventLoginServerMaximumSessionsExceededHandler.java create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/RegisterSessionServerAuthenticationSuccessHandler.java create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/ServerMaximumSessionsExceededHandler.java create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/SessionLimit.java create mode 100644 web/src/main/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistry.java create mode 100644 web/src/test/java/org/springframework/security/web/server/authentication/session/ConcurrentSessionControlServerAuthenticationSuccessHandlerTests.java create mode 100644 web/src/test/java/org/springframework/security/web/server/authentication/session/InMemoryReactiveSessionRegistryTests.java create mode 100644 web/src/test/java/org/springframework/security/web/server/authentication/session/InvalidateLeastUsedServerMaximumSessionsExceededHandlerTests.java create mode 100644 web/src/test/java/org/springframework/security/web/server/authentication/session/PreventLoginServerMaximumSessionsExceededHandlerTests.java create mode 100644 web/src/test/java/org/springframework/security/web/server/authentication/session/RegisterSessionServerAuthenticationSuccessHandlerTests.java create mode 100644 web/src/test/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistryTests.java diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 3df5775afc..6bd9570cf9 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -62,6 +62,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.session.ReactiveSessionRegistry; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.oauth2.client.InMemoryReactiveOAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; @@ -124,19 +125,26 @@ import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.AnonymousAuthenticationWebFilter; import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.authentication.ConcurrentSessionControlServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.DelegatingServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.HttpBasicServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint; +import org.springframework.security.web.server.authentication.InvalidateLeastUsedServerMaximumSessionsExceededHandler; import org.springframework.security.web.server.authentication.ReactivePreAuthenticatedAuthenticationManager; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.RegisterSessionServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; import org.springframework.security.web.server.authentication.ServerAuthenticationEntryPointFailureHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter; import org.springframework.security.web.server.authentication.ServerHttpBasicAuthenticationConverter; +import org.springframework.security.web.server.authentication.ServerMaximumSessionsExceededHandler; import org.springframework.security.web.server.authentication.ServerX509AuthenticationConverter; +import org.springframework.security.web.server.authentication.SessionLimit; +import org.springframework.security.web.server.authentication.WebFilterChainServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.logout.DelegatingServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.LogoutWebFilter; import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler; @@ -312,6 +320,8 @@ public class ServerHttpSecurity { private LoginPageSpec loginPage = new LoginPageSpec(); + private SessionManagementSpec sessionManagement; + private ReactiveAuthenticationManager authenticationManager; private ServerSecurityContextRepository securityContextRepository; @@ -360,6 +370,7 @@ public class ServerHttpSecurity { } /** + * * Adds a {@link WebFilter} before specific position. * @param webFilter the {@link WebFilter} to add * @param order the place before which to insert the {@link WebFilter} @@ -743,6 +754,36 @@ public class ServerHttpSecurity { return this; } + /** + * Configures Session Management. An example configuration is provided below: + *
+	 *  @Bean
+	 *  SecurityWebFilterChain filterChain(ServerHttpSecurity http, ReactiveSessionRegistry sessionRegistry) {
+	 *      http
+	 *          // ...
+	 *          .sessionManagement((sessionManagement) -> sessionManagement
+	 *              .concurrentSessions((concurrentSessions) -> concurrentSessions
+	 *                  .maxSessions(1)
+	 *                  .maxSessionsPreventsLogin(true)
+	 *                  .sessionRegistry(sessionRegistry)
+	 *              )
+	 *          );
+	 *      return http.build();
+	 *  }
+	 * 
+ * @param customizer the {@link Customizer} to provide more options for the + * {@link SessionManagementSpec} + * @return the {@link ServerHttpSecurity} to continue configuring + * @since 6.3 + */ + public ServerHttpSecurity sessionManagement(Customizer customizer) { + if (this.sessionManagement == null) { + this.sessionManagement = new SessionManagementSpec(); + } + customizer.customize(this.sessionManagement); + return this; + } + /** * Configures password management. An example configuration is provided below: * @@ -1517,6 +1558,9 @@ public class ServerHttpSecurity { } WebFilter securityContextRepositoryWebFilter = securityContextRepositoryWebFilter(); this.webFilters.add(securityContextRepositoryWebFilter); + if (this.sessionManagement != null) { + this.sessionManagement.configure(this); + } if (this.httpsRedirectSpec != null) { this.httpsRedirectSpec.configure(this); } @@ -1907,6 +1951,249 @@ public class ServerHttpSecurity { } + /** + * Configures how sessions are managed. + */ + public class SessionManagementSpec { + + private ConcurrentSessionsSpec concurrentSessions; + + private ServerAuthenticationSuccessHandler authenticationSuccessHandler; + + private ReactiveSessionRegistry sessionRegistry; + + private SessionLimit sessionLimit = SessionLimit.UNLIMITED; + + private ServerMaximumSessionsExceededHandler maximumSessionsExceededHandler = new InvalidateLeastUsedServerMaximumSessionsExceededHandler(); + + /** + * Configures how many sessions are allowed for a given user. + * @param customizer the customizer to provide more options + * @return the {@link SessionManagementSpec} to customize + */ + public SessionManagementSpec concurrentSessions(Customizer customizer) { + if (this.concurrentSessions == null) { + this.concurrentSessions = new ConcurrentSessionsSpec(); + } + customizer.customize(this.concurrentSessions); + return this; + } + + void configure(ServerHttpSecurity http) { + if (this.concurrentSessions != null) { + ReactiveSessionRegistry reactiveSessionRegistry = getSessionRegistry(); + ConcurrentSessionControlServerAuthenticationSuccessHandler concurrentSessionControlStrategy = new ConcurrentSessionControlServerAuthenticationSuccessHandler( + reactiveSessionRegistry); + concurrentSessionControlStrategy.setSessionLimit(this.sessionLimit); + concurrentSessionControlStrategy.setMaximumSessionsExceededHandler(this.maximumSessionsExceededHandler); + RegisterSessionServerAuthenticationSuccessHandler registerSessionAuthenticationStrategy = new RegisterSessionServerAuthenticationSuccessHandler( + reactiveSessionRegistry); + this.authenticationSuccessHandler = new DelegatingServerAuthenticationSuccessHandler( + concurrentSessionControlStrategy, registerSessionAuthenticationStrategy); + SessionRegistryWebFilter sessionRegistryWebFilter = new SessionRegistryWebFilter( + reactiveSessionRegistry); + configureSuccessHandlerOnAuthenticationFilters(); + http.addFilterAfter(sessionRegistryWebFilter, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); + } + } + + private void configureSuccessHandlerOnAuthenticationFilters() { + if (ServerHttpSecurity.this.formLogin != null) { + ServerHttpSecurity.this.formLogin.defaultSuccessHandlers.add(0, this.authenticationSuccessHandler); + } + if (ServerHttpSecurity.this.oauth2Login != null) { + ServerHttpSecurity.this.oauth2Login.defaultSuccessHandlers.add(0, this.authenticationSuccessHandler); + } + if (ServerHttpSecurity.this.httpBasic != null) { + ServerHttpSecurity.this.httpBasic.defaultSuccessHandlers.add(0, this.authenticationSuccessHandler); + } + } + + private ReactiveSessionRegistry getSessionRegistry() { + if (this.sessionRegistry == null) { + this.sessionRegistry = getBeanOrNull(ReactiveSessionRegistry.class); + } + if (this.sessionRegistry == null) { + throw new IllegalStateException( + "A ReactiveSessionRegistry is needed for concurrent session management"); + } + return this.sessionRegistry; + } + + /** + * Configures how many sessions are allowed for a given user. + */ + public class ConcurrentSessionsSpec { + + /** + * Sets the {@link ReactiveSessionRegistry} to use. + * @param reactiveSessionRegistry the {@link ReactiveSessionRegistry} to use + * @return the {@link ConcurrentSessionsSpec} to continue customizing + */ + public ConcurrentSessionsSpec sessionRegistry(ReactiveSessionRegistry reactiveSessionRegistry) { + SessionManagementSpec.this.sessionRegistry = reactiveSessionRegistry; + return this; + } + + /** + * Sets the maximum number of sessions allowed for any user. You can use + * {@link SessionLimit#of(int)} to specify a positive integer or + * {@link SessionLimit#UNLIMITED} to allow unlimited sessions. To customize + * the maximum number of sessions on a per-user basis, you can provide a + * custom {@link SessionLimit} implementation, like so:
+			 *     http
+			 *         .sessionManagement((sessions) -> sessions
+			 *             .concurrentSessions((concurrency) -> concurrency
+			 *                 .maximumSessions((authentication) -> {
+			 *                     if (authentication.getName().equals("admin")) {
+			 *                         return Mono.empty() // unlimited sessions for admin
+			 *                     }
+			 *                     return Mono.just(1); // one session for every other user
+			 *                 })
+			 *             )
+			 *         )
+			 * 
+ * @param sessionLimit the maximum number of sessions allowed for any user + * @return the {@link ConcurrentSessionsSpec} to continue customizing + */ + public ConcurrentSessionsSpec maximumSessions(SessionLimit sessionLimit) { + Assert.notNull(sessionLimit, "sessionLimit cannot be null"); + SessionManagementSpec.this.sessionLimit = sessionLimit; + return this; + } + + /** + * Sets the {@link ServerMaximumSessionsExceededHandler} to use when the + * maximum number of sessions is exceeded. + * @param maximumSessionsExceededHandler the + * {@link ServerMaximumSessionsExceededHandler} to use + * @return the {@link ConcurrentSessionsSpec} to continue customizing + */ + public ConcurrentSessionsSpec maximumSessionsExceededHandler( + ServerMaximumSessionsExceededHandler maximumSessionsExceededHandler) { + Assert.notNull(maximumSessionsExceededHandler, "maximumSessionsExceededHandler cannot be null"); + SessionManagementSpec.this.maximumSessionsExceededHandler = maximumSessionsExceededHandler; + return this; + } + + } + + private static final class SessionRegistryWebFilter implements WebFilter { + + private final ReactiveSessionRegistry sessionRegistry; + + private SessionRegistryWebFilter(ReactiveSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return chain.filter(new SessionRegistryWebExchange(exchange)); + } + + private final class SessionRegistryWebExchange extends ServerWebExchangeDecorator { + + private final Mono sessionMono; + + private SessionRegistryWebExchange(ServerWebExchange delegate) { + super(delegate); + this.sessionMono = delegate.getSession() + .flatMap((session) -> SessionRegistryWebFilter.this.sessionRegistry + .updateLastAccessTime(session.getId()) + .thenReturn(session)) + .map(SessionRegistryWebSession::new); + } + + @Override + public Mono getSession() { + return this.sessionMono; + } + + } + + private final class SessionRegistryWebSession implements WebSession { + + private final WebSession session; + + private SessionRegistryWebSession(WebSession session) { + this.session = session; + } + + @Override + public String getId() { + return this.session.getId(); + } + + @Override + public Map getAttributes() { + return this.session.getAttributes(); + } + + @Override + public void start() { + this.session.start(); + } + + @Override + public boolean isStarted() { + return this.session.isStarted(); + } + + @Override + public Mono changeSessionId() { + String currentId = this.session.getId(); + return SessionRegistryWebFilter.this.sessionRegistry.removeSessionInformation(currentId) + .flatMap((information) -> this.session.changeSessionId().thenReturn(information)) + .flatMap((information) -> { + information = information.withSessionId(this.session.getId()); + return SessionRegistryWebFilter.this.sessionRegistry.saveSessionInformation(information); + }); + } + + @Override + public Mono invalidate() { + String currentId = this.session.getId(); + return SessionRegistryWebFilter.this.sessionRegistry.removeSessionInformation(currentId) + .flatMap((information) -> this.session.invalidate()); + } + + @Override + public Mono save() { + return this.session.save(); + } + + @Override + public boolean isExpired() { + return this.session.isExpired(); + } + + @Override + public Instant getCreationTime() { + return this.session.getCreationTime(); + } + + @Override + public Instant getLastAccessTime() { + return this.session.getLastAccessTime(); + } + + @Override + public void setMaxIdleTime(Duration maxIdleTime) { + this.session.setMaxIdleTime(maxIdleTime); + } + + @Override + public Duration getMaxIdleTime() { + return this.session.getMaxIdleTime(); + } + + } + + } + + } + /** * Configures HTTPS redirection rules * @@ -2211,6 +2498,11 @@ public class ServerHttpSecurity { private ServerAuthenticationFailureHandler authenticationFailureHandler; + private final List defaultSuccessHandlers = new ArrayList<>( + List.of(new WebFilterChainServerAuthenticationSuccessHandler())); + + private List authenticationSuccessHandlers = new ArrayList<>(); + private HttpBasicSpec() { List entryPoints = new ArrayList<>(); entryPoints @@ -2221,6 +2513,40 @@ public class ServerHttpSecurity { this.entryPoint = defaultEntryPoint; } + /** + * The {@link ServerAuthenticationSuccessHandler} used after authentication + * success. Defaults to {@link WebFilterChainServerAuthenticationSuccessHandler}. + * Note that this method clears previously added success handlers via + * {@link #authenticationSuccessHandler(Consumer)} + * @param authenticationSuccessHandler the success handler to use + * @return the {@link HttpBasicSpec} to continue configuring + * @since 6.3 + */ + public HttpBasicSpec authenticationSuccessHandler( + ServerAuthenticationSuccessHandler authenticationSuccessHandler) { + Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); + authenticationSuccessHandler((handlers) -> { + handlers.clear(); + handlers.add(authenticationSuccessHandler); + }); + return this; + } + + /** + * Allows customizing the list of {@link ServerAuthenticationSuccessHandler}. The + * default list contains a + * {@link WebFilterChainServerAuthenticationSuccessHandler}. + * @param handlersConsumer the handlers consumer + * @return the {@link HttpBasicSpec} to continue configuring + * @since 6.3 + */ + public HttpBasicSpec authenticationSuccessHandler( + Consumer> handlersConsumer) { + Assert.notNull(handlersConsumer, "handlersConsumer cannot be null"); + handlersConsumer.accept(this.authenticationSuccessHandlers); + return this; + } + /** * The {@link ReactiveAuthenticationManager} used to authenticate. Defaults to * {@link ServerHttpSecurity#authenticationManager(ReactiveAuthenticationManager)}. @@ -2306,9 +2632,17 @@ public class ServerHttpSecurity { authenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler()); authenticationFilter.setAuthenticationConverter(new ServerHttpBasicAuthenticationConverter()); authenticationFilter.setSecurityContextRepository(this.securityContextRepository); + authenticationFilter.setAuthenticationSuccessHandler(getAuthenticationSuccessHandler(http)); http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.HTTP_BASIC); } + private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler(ServerHttpSecurity http) { + if (this.authenticationSuccessHandlers.isEmpty()) { + return new DelegatingServerAuthenticationSuccessHandler(this.defaultSuccessHandlers); + } + return new DelegatingServerAuthenticationSuccessHandler(this.authenticationSuccessHandlers); + } + private ServerAuthenticationFailureHandler authenticationFailureHandler() { if (this.authenticationFailureHandler != null) { return this.authenticationFailureHandler; @@ -2380,6 +2714,9 @@ public class ServerHttpSecurity { private final RedirectServerAuthenticationSuccessHandler defaultSuccessHandler = new RedirectServerAuthenticationSuccessHandler( "/"); + private final List defaultSuccessHandlers = new ArrayList<>( + List.of(this.defaultSuccessHandler)); + private RedirectServerAuthenticationEntryPoint defaultEntryPoint; private ReactiveAuthenticationManager authenticationManager; @@ -2394,7 +2731,7 @@ public class ServerHttpSecurity { private ServerAuthenticationFailureHandler authenticationFailureHandler; - private ServerAuthenticationSuccessHandler authenticationSuccessHandler = this.defaultSuccessHandler; + private List authenticationSuccessHandlers = new ArrayList<>(); private FormLoginSpec() { } @@ -2412,14 +2749,34 @@ public class ServerHttpSecurity { /** * The {@link ServerAuthenticationSuccessHandler} used after authentication - * success. Defaults to {@link RedirectServerAuthenticationSuccessHandler}. + * success. Defaults to {@link RedirectServerAuthenticationSuccessHandler}. Note + * that this method clears previously added success handlers via + * {@link #authenticationSuccessHandler(Consumer)} * @param authenticationSuccessHandler the success handler to use * @return the {@link FormLoginSpec} to continue configuring */ public FormLoginSpec authenticationSuccessHandler( ServerAuthenticationSuccessHandler authenticationSuccessHandler) { Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); - this.authenticationSuccessHandler = authenticationSuccessHandler; + authenticationSuccessHandler((handlers) -> { + handlers.clear(); + handlers.add(authenticationSuccessHandler); + }); + return this; + } + + /** + * Allows customizing the list of {@link ServerAuthenticationSuccessHandler}. The + * default list contains a {@link RedirectServerAuthenticationSuccessHandler} that + * redirects to "/". + * @param handlersConsumer the handlers consumer + * @return the {@link FormLoginSpec} to continue configuring + * @since 6.3 + */ + public FormLoginSpec authenticationSuccessHandler( + Consumer> handlersConsumer) { + Assert.notNull(handlersConsumer, "handlersConsumer cannot be null"); + handlersConsumer.accept(this.authenticationSuccessHandlers); return this; } @@ -2552,11 +2909,18 @@ public class ServerHttpSecurity { authenticationFilter.setRequiresAuthenticationMatcher(this.requiresAuthenticationMatcher); authenticationFilter.setAuthenticationFailureHandler(this.authenticationFailureHandler); authenticationFilter.setAuthenticationConverter(new ServerFormLoginAuthenticationConverter()); - authenticationFilter.setAuthenticationSuccessHandler(this.authenticationSuccessHandler); + authenticationFilter.setAuthenticationSuccessHandler(getAuthenticationSuccessHandler(http)); authenticationFilter.setSecurityContextRepository(this.securityContextRepository); http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.FORM_LOGIN); } + private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler(ServerHttpSecurity http) { + if (this.authenticationSuccessHandlers.isEmpty()) { + return new DelegatingServerAuthenticationSuccessHandler(this.defaultSuccessHandlers); + } + return new DelegatingServerAuthenticationSuccessHandler(this.authenticationSuccessHandlers); + } + } private final class LoginPageSpec { @@ -3735,7 +4099,12 @@ public class ServerHttpSecurity { private ReactiveOidcSessionRegistry oidcSessionRegistry; - private ServerAuthenticationSuccessHandler authenticationSuccessHandler; + private final RedirectServerAuthenticationSuccessHandler defaultAuthenticationSuccessHandler = new RedirectServerAuthenticationSuccessHandler(); + + private final List defaultSuccessHandlers = new ArrayList<>( + List.of(this.defaultAuthenticationSuccessHandler)); + + private List authenticationSuccessHandlers = new ArrayList<>(); private ServerAuthenticationFailureHandler authenticationFailureHandler; @@ -3783,7 +4152,8 @@ public class ServerHttpSecurity { /** * The {@link ServerAuthenticationSuccessHandler} used after authentication * success. Defaults to {@link RedirectServerAuthenticationSuccessHandler} - * redirecting to "/". + * redirecting to "/". Note that this method clears previously added success + * handlers via {@link #authenticationSuccessHandler(Consumer)} * @param authenticationSuccessHandler the success handler to use * @return the {@link OAuth2LoginSpec} to customize * @since 5.2 @@ -3791,7 +4161,25 @@ public class ServerHttpSecurity { public OAuth2LoginSpec authenticationSuccessHandler( ServerAuthenticationSuccessHandler authenticationSuccessHandler) { Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); - this.authenticationSuccessHandler = authenticationSuccessHandler; + authenticationSuccessHandler((handlers) -> { + handlers.clear(); + handlers.add(authenticationSuccessHandler); + }); + return this; + } + + /** + * Allows customizing the list of {@link ServerAuthenticationSuccessHandler}. The + * default list contains a {@link RedirectServerAuthenticationSuccessHandler} that + * redirects to "/". + * @param handlersConsumer the handlers consumer + * @return the {@link OAuth2LoginSpec} to continue configuring + * @since 6.3 + */ + public OAuth2LoginSpec authenticationSuccessHandler( + Consumer> handlersConsumer) { + Assert.notNull(handlersConsumer, "handlersConsumer cannot be null"); + handlersConsumer.accept(this.authenticationSuccessHandlers); return this; } @@ -4041,12 +4429,11 @@ public class ServerHttpSecurity { } private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler(ServerHttpSecurity http) { - if (this.authenticationSuccessHandler == null) { - RedirectServerAuthenticationSuccessHandler handler = new RedirectServerAuthenticationSuccessHandler(); - handler.setRequestCache(http.requestCache.requestCache); - this.authenticationSuccessHandler = handler; + this.defaultAuthenticationSuccessHandler.setRequestCache(http.requestCache.requestCache); + if (this.authenticationSuccessHandlers.isEmpty()) { + return new DelegatingServerAuthenticationSuccessHandler(this.defaultSuccessHandlers); } - return this.authenticationSuccessHandler; + return new DelegatingServerAuthenticationSuccessHandler(this.authenticationSuccessHandlers); } private ServerAuthenticationFailureHandler getAuthenticationFailureHandler() { diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt index 300a3d6a60..639130f17f 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt @@ -682,6 +682,36 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in this.http.oidcLogout(oidcLogoutCustomizer) } + /** + * Configures Session Management support. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebFluxSecurity + * open class SecurityConfig { + * + * @Bean + * open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + * return http { + * sessionManagement { + * sessionConcurrency { } + * } + * } + * } + * } + * ``` + * + * @param sessionManagementConfig custom configuration to configure the Session Management + * @since 6.3 + * @see [ServerSessionManagementDsl] + */ + fun sessionManagement(sessionManagementConfig: ServerSessionManagementDsl.() -> Unit) { + val sessionManagementCustomizer = ServerSessionManagementDsl().apply(sessionManagementConfig).get() + this.http.sessionManagement(sessionManagementCustomizer) + } + /** * Apply all configurations to the provided [ServerHttpSecurity] */ diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionConcurrencyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionConcurrencyDsl.kt new file mode 100644 index 0000000000..b2dad3a574 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionConcurrencyDsl.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2002-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 org.springframework.security.config.web.server + +import org.springframework.security.core.session.ReactiveSessionRegistry +import org.springframework.security.web.server.authentication.ServerMaximumSessionsExceededHandler +import org.springframework.security.web.server.authentication.SessionLimit + +/** + * A Kotlin DSL to configure [ServerHttpSecurity] Session Concurrency support using idiomatic Kotlin code. + * + * @author Marcus da Coregio + * @since 6.3 + */ +@ServerSecurityMarker +class ServerSessionConcurrencyDsl { + var maximumSessions: SessionLimit? = null + var maximumSessionsExceededHandler: ServerMaximumSessionsExceededHandler? = null + var sessionRegistry: ReactiveSessionRegistry? = null + + internal fun get(): (ServerHttpSecurity.SessionManagementSpec.ConcurrentSessionsSpec) -> Unit { + return { sessionConcurrency -> + maximumSessions?.also { + sessionConcurrency.maximumSessions(maximumSessions!!) + } + maximumSessionsExceededHandler?.also { + sessionConcurrency.maximumSessionsExceededHandler(maximumSessionsExceededHandler!!) + } + sessionRegistry?.also { + sessionConcurrency.sessionRegistry(sessionRegistry!!) + } + } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDsl.kt new file mode 100644 index 0000000000..4858baa244 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDsl.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2002-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 org.springframework.security.config.web.server + +/** + * A Kotlin DSL to configure [ServerHttpSecurity] Session Management using idiomatic Kotlin code. + * + * @author Marcus da Coregio + * @since 6.3 + */ +@ServerSecurityMarker +class ServerSessionManagementDsl { + private var sessionConcurrency: ((ServerHttpSecurity.SessionManagementSpec.ConcurrentSessionsSpec) -> Unit)? = null + + /** + * Enables Session Management support. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebFluxSecurity + * open class SecurityConfig { + * + * @Bean + * open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + * return http { + * sessionManagement { + * sessionConcurrency { + * maximumSessions = { authentication -> Mono.just(1) } + * } + * } + * } + * } + * } + * ``` + * + * @param backChannelConfig custom configurations to configure OIDC 1.0 Back-Channel Logout support + * @see [ServerOidcBackChannelLogoutDsl] + */ + fun sessionConcurrency(sessionConcurrencyConfig: ServerSessionConcurrencyDsl.() -> Unit) { + this.sessionConcurrency = ServerSessionConcurrencyDsl().apply(sessionConcurrencyConfig).get() + } + + internal fun get(): (ServerHttpSecurity.SessionManagementSpec) -> Unit { + return { sessionManagement -> + sessionConcurrency?.also { sessionManagement.concurrentSessions(sessionConcurrency) } + } + } +} diff --git a/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java b/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java index b8bcb40a67..fe3a0b4c2f 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java @@ -56,9 +56,11 @@ import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.security.web.server.authentication.AnonymousAuthenticationWebFilterTests; +import org.springframework.security.web.server.authentication.DelegatingServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.HttpBasicServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint; import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.ServerX509AuthenticationConverter; import org.springframework.security.web.server.authentication.logout.DelegatingServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.LogoutWebFilter; @@ -592,6 +594,7 @@ public class ServerHttpSecurityTests { } @Test + @SuppressWarnings("unchecked") public void shouldConfigureRequestCacheForOAuth2LoginAuthenticationEntryPointAndSuccessHandler() { ServerRequestCache requestCache = spy(new WebSessionServerRequestCache()); ReactiveClientRegistrationRepository clientRegistrationRepository = mock( @@ -613,8 +616,11 @@ public class ServerHttpSecurityTests { OAuth2LoginAuthenticationWebFilter authenticationWebFilter = getWebFilter(securityFilterChain, OAuth2LoginAuthenticationWebFilter.class) .get(); - Object handler = ReflectionTestUtils.getField(authenticationWebFilter, "authenticationSuccessHandler"); - assertThat(ReflectionTestUtils.getField(handler, "requestCache")).isSameAs(requestCache); + DelegatingServerAuthenticationSuccessHandler handler = (DelegatingServerAuthenticationSuccessHandler) ReflectionTestUtils + .getField(authenticationWebFilter, "authenticationSuccessHandler"); + List delegates = (List) ReflectionTestUtils + .getField(handler, "delegates"); + assertThat(ReflectionTestUtils.getField(delegates.get(0), "requestCache")).isSameAs(requestCache); } @Test diff --git a/config/src/test/java/org/springframework/security/config/web/server/SessionManagementSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/SessionManagementSpecTests.java new file mode 100644 index 0000000000..ca2fcf9f80 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/web/server/SessionManagementSpecTests.java @@ -0,0 +1,624 @@ +/* + * Copyright 2002-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 org.springframework.security.config.web.server; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.config.users.ReactiveAuthenticationTestConfiguration; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.security.core.userdetails.PasswordEncodedUser; +import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.TestOAuth2AccessTokens; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.TestOAuth2AuthorizationExchanges; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.core.user.TestOAuth2Users; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.InvalidateLeastUsedServerMaximumSessionsExceededHandler; +import org.springframework.security.web.server.authentication.PreventLoginServerMaximumSessionsExceededHandler; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.SessionLimit; +import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; +import org.springframework.security.web.session.WebSessionStoreReactiveSessionRegistry; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import org.springframework.web.server.session.DefaultWebSessionManager; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; + +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +public class SessionManagementSpecTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + WebTestClient client; + + @Autowired + public void setApplicationContext(ApplicationContext context) { + this.client = WebTestClient.bindToApplicationContext(context).build(); + } + + @Test + void loginWhenMaxSessionPreventsLoginThenSecondLoginFails() { + this.spring.register(ConcurrentSessionsMaxSessionPreventsLoginConfig.class).autowire(); + + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + ResponseCookie firstLoginSessionCookie = loginReturningCookie(data); + + // second login should fail + this.client.mutateWith(csrf()) + .post() + .uri("/login") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromFormData(data)) + .exchange() + .expectHeader() + .location("/login?error"); + + // first login should still be valid + this.client.mutateWith(csrf()) + .get() + .uri("/") + .cookie(firstLoginSessionCookie.getName(), firstLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void httpBasicWhenUsingSavingAuthenticationInWebSessionAndPreventLoginThenSecondRequestFails() { + this.spring.register(ConcurrentSessionsHttpBasicWithWebSessionMaxSessionPreventsLoginConfig.class).autowire(); + + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + // first request be successful + ResponseCookie sessionCookie = this.client.get() + .uri("/") + .headers((headers) -> headers.setBasicAuth("user", "password")) + .exchange() + .expectStatus() + .isOk() + .expectCookie() + .exists("SESSION") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + + // request with no session should fail + this.client.get() + .uri("/") + .headers((headers) -> headers.setBasicAuth("user", "password")) + .exchange() + .expectStatus() + .isUnauthorized(); + + // request with session obtained from first request should be successful + this.client.get() + .uri("/") + .headers((headers) -> headers.setBasicAuth("user", "password")) + .cookie(sessionCookie.getName(), sessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void loginWhenMaxSessionPerAuthenticationThenUserLoginFailsAndAdminLoginSucceeds() { + ConcurrentSessionsMaxSessionPreventsLoginConfig.sessionLimit = (authentication) -> { + if (authentication.getName().equals("admin")) { + return Mono.empty(); + } + return Mono.just(1); + }; + this.spring.register(ConcurrentSessionsMaxSessionPreventsLoginConfig.class).autowire(); + + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + MultiValueMap adminCreds = new LinkedMultiValueMap<>(); + adminCreds.add("username", "admin"); + adminCreds.add("password", "password"); + + ResponseCookie userFirstLoginSessionCookie = loginReturningCookie(data); + ResponseCookie adminFirstLoginSessionCookie = loginReturningCookie(adminCreds); + // second user login should fail + this.client.mutateWith(csrf()) + .post() + .uri("/login") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromFormData(data)) + .exchange() + .expectHeader() + .location("/login?error"); + // first login should still be valid + this.client.mutateWith(csrf()) + .get() + .uri("/") + .cookie(userFirstLoginSessionCookie.getName(), userFirstLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + ResponseCookie adminSecondLoginSessionCookie = loginReturningCookie(adminCreds); + this.client.mutateWith(csrf()) + .get() + .uri("/") + .cookie(adminFirstLoginSessionCookie.getName(), adminFirstLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + this.client.mutateWith(csrf()) + .get() + .uri("/") + .cookie(adminSecondLoginSessionCookie.getName(), adminSecondLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void loginWhenMaxSessionDoesNotPreventLoginThenSecondLoginSucceedsAndFirstSessionIsInvalidated() { + ConcurrentSessionsMaxSessionPreventsLoginFalseConfig.sessionLimit = SessionLimit.of(1); + this.spring.register(ConcurrentSessionsMaxSessionPreventsLoginFalseConfig.class).autowire(); + + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + ResponseCookie firstLoginSessionCookie = loginReturningCookie(data); + ResponseCookie secondLoginSessionCookie = loginReturningCookie(data); + + // first login should not be valid + this.client.get() + .uri("/") + .cookie(firstLoginSessionCookie.getName(), firstLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isFound() + .expectHeader() + .location("/login"); + + // second login should be valid + this.client.get() + .uri("/") + .cookie(secondLoginSessionCookie.getName(), secondLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void loginWhenMaxSessionDoesNotPreventLoginThenLeastRecentlyUsedSessionIsInvalidated() { + ConcurrentSessionsMaxSessionPreventsLoginFalseConfig.sessionLimit = SessionLimit.of(2); + this.spring.register(ConcurrentSessionsMaxSessionPreventsLoginFalseConfig.class).autowire(); + + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + ResponseCookie firstLoginSessionCookie = loginReturningCookie(data); + ResponseCookie secondLoginSessionCookie = loginReturningCookie(data); + + // update last access time for first request + this.client.get() + .uri("/") + .cookie(firstLoginSessionCookie.getName(), firstLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + + ResponseCookie thirdLoginSessionCookie = loginReturningCookie(data); + + // second login should be invalid, it is the least recently used session + this.client.get() + .uri("/") + .cookie(secondLoginSessionCookie.getName(), secondLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isFound() + .expectHeader() + .location("/login"); + + // first login should be valid + this.client.get() + .uri("/") + .cookie(firstLoginSessionCookie.getName(), firstLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + + // third login should be valid + this.client.get() + .uri("/") + .cookie(thirdLoginSessionCookie.getName(), thirdLoginSessionCookie.getValue()) + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void oauth2LoginWhenMaxSessionsThenPreventLogin() { + OAuth2LoginConcurrentSessionsConfig.maxSessions = 1; + OAuth2LoginConcurrentSessionsConfig.preventLogin = true; + this.spring.register(OAuth2LoginConcurrentSessionsConfig.class).autowire(); + prepareOAuth2Config(); + // @formatter:off + ResponseCookie sessionCookie = this.client.get() + .uri("/login/oauth2/code/client-credentials") + .exchange() + .expectStatus().is3xxRedirection() + .expectHeader().valueEquals("Location", "/") + .expectCookie().exists("SESSION") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + + this.client.get() + .uri("/login/oauth2/code/client-credentials") + .exchange() + .expectHeader().location("/login?error"); + + this.client.get().uri("/") + .cookie(sessionCookie.getName(), sessionCookie.getValue()) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("ok"); + // @formatter:on + } + + @Test + void loginWhenUnlimitedSessionsButSessionsInvalidatedManuallyThenInvalidates() { + ConcurrentSessionsMaxSessionPreventsLoginFalseConfig.sessionLimit = SessionLimit.UNLIMITED; + this.spring.register(ConcurrentSessionsMaxSessionPreventsLoginFalseConfig.class).autowire(); + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + + ResponseCookie firstLogin = loginReturningCookie(data); + ResponseCookie secondLogin = loginReturningCookie(data); + this.client.get().uri("/").cookie(firstLogin.getName(), firstLogin.getValue()).exchange().expectStatus().isOk(); + this.client.get() + .uri("/") + .cookie(secondLogin.getName(), secondLogin.getValue()) + .exchange() + .expectStatus() + .isOk(); + ReactiveSessionRegistry sessionRegistry = this.spring.getContext().getBean(ReactiveSessionRegistry.class); + sessionRegistry.getAllSessions(PasswordEncodedUser.user(), false) + .flatMap(ReactiveSessionInformation::invalidate) + .blockLast(); + this.client.get() + .uri("/") + .cookie(firstLogin.getName(), firstLogin.getValue()) + .exchange() + .expectStatus() + .isFound() + .expectHeader() + .location("/login"); + this.client.get() + .uri("/") + .cookie(secondLogin.getName(), secondLogin.getValue()) + .exchange() + .expectStatus() + .isFound() + .expectHeader() + .location("/login"); + } + + @Test + void oauth2LoginWhenMaxSessionDoesNotPreventLoginThenSecondLoginSucceedsAndFirstSessionIsInvalidated() { + OAuth2LoginConcurrentSessionsConfig.maxSessions = 1; + OAuth2LoginConcurrentSessionsConfig.preventLogin = false; + this.spring.register(OAuth2LoginConcurrentSessionsConfig.class).autowire(); + prepareOAuth2Config(); + // @formatter:off + ResponseCookie firstLoginCookie = this.client.get() + .uri("/login/oauth2/code/client-credentials") + .exchange() + .expectStatus().is3xxRedirection() + .expectHeader().valueEquals("Location", "/") + .expectCookie().exists("SESSION") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + ResponseCookie secondLoginCookie = this.client.get() + .uri("/login/oauth2/code/client-credentials") + .exchange() + .expectStatus().is3xxRedirection() + .expectHeader().valueEquals("Location", "/") + .expectCookie().exists("SESSION") + .returnResult(Void.class) + .getResponseCookies() + .getFirst("SESSION"); + + this.client.get().uri("/") + .cookie(firstLoginCookie.getName(), firstLoginCookie.getValue()) + .exchange() + .expectStatus().isFound() + .expectHeader().location("/login"); + + this.client.get().uri("/") + .cookie(secondLoginCookie.getName(), secondLoginCookie.getValue()) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("ok"); + // @formatter:on + } + + @Test + void loginWhenAuthenticationSuccessHandlerOverriddenThenConcurrentSessionHandlersBackOff() { + this.spring.register(ConcurrentSessionsFormLoginOverrideAuthenticationSuccessHandlerConfig.class).autowire(); + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + // first login should be successful + login(data).expectStatus().isFound().expectHeader().location("/"); + // second login should be successful, there should be no concurrent session + // control + login(data).expectStatus().isFound().expectHeader().location("/"); + } + + private void prepareOAuth2Config() { + OAuth2LoginConcurrentSessionsConfig config = this.spring.getContext() + .getBean(OAuth2LoginConcurrentSessionsConfig.class); + ServerAuthenticationConverter converter = config.authenticationConverter; + ReactiveAuthenticationManager manager = config.manager; + ServerOAuth2AuthorizationRequestResolver resolver = config.resolver; + OAuth2AuthorizationExchange exchange = TestOAuth2AuthorizationExchanges.success(); + OAuth2User user = TestOAuth2Users.create(); + OAuth2AccessToken accessToken = TestOAuth2AccessTokens.noScopes(); + OAuth2LoginAuthenticationToken result = new OAuth2LoginAuthenticationToken( + TestClientRegistrations.clientRegistration().build(), exchange, user, user.getAuthorities(), + accessToken); + given(converter.convert(any())).willReturn(Mono.just(new TestingAuthenticationToken("a", "b", "c"))); + given(manager.authenticate(any())).willReturn(Mono.just(result)); + given(resolver.resolve(any())).willReturn(Mono.empty()); + } + + 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(); + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(Config.class) + static class ConcurrentSessionsMaxSessionPreventsLoginConfig { + + static SessionLimit sessionLimit = SessionLimit.of(1); + + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + // @formatter:off + http + .authorizeExchange((exchanges) -> exchanges.anyExchange().authenticated()) + .formLogin(Customizer.withDefaults()) + .sessionManagement((sessionManagement) -> sessionManagement + .concurrentSessions((concurrentSessions) -> concurrentSessions + .maximumSessions(sessionLimit) + .maximumSessionsExceededHandler(new PreventLoginServerMaximumSessionsExceededHandler()) + ) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(Config.class) + static class OAuth2LoginConcurrentSessionsConfig { + + static int maxSessions = 1; + + static boolean preventLogin = true; + + ReactiveAuthenticationManager manager = mock(ReactiveAuthenticationManager.class); + + ServerAuthenticationConverter authenticationConverter = mock(ServerAuthenticationConverter.class); + + ServerOAuth2AuthorizationRequestResolver resolver = mock(ServerOAuth2AuthorizationRequestResolver.class); + + ServerAuthenticationSuccessHandler successHandler = mock(ServerAuthenticationSuccessHandler.class); + + @Bean + SecurityWebFilterChain springSecurityFilter(ServerHttpSecurity http) { + // @formatter:off + http + .authorizeExchange((exchanges) -> exchanges + .anyExchange().authenticated() + ) + .oauth2Login((oauth2Login) -> oauth2Login + .authenticationConverter(this.authenticationConverter) + .authenticationManager(this.manager) + .authorizationRequestResolver(this.resolver) + ) + .sessionManagement((sessionManagement) -> sessionManagement + .concurrentSessions((concurrentSessions) -> concurrentSessions + .maximumSessions(SessionLimit.of(maxSessions)) + .maximumSessionsExceededHandler(preventLogin + ? new PreventLoginServerMaximumSessionsExceededHandler() + : new InvalidateLeastUsedServerMaximumSessionsExceededHandler()) + ) + ); + // @formatter:on + return http.build(); + } + + @Bean + InMemoryReactiveClientRegistrationRepository clientRegistrationRepository() { + return new InMemoryReactiveClientRegistrationRepository( + TestClientRegistrations.clientCredentials().build()); + } + + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(Config.class) + static class ConcurrentSessionsMaxSessionPreventsLoginFalseConfig { + + static SessionLimit sessionLimit = SessionLimit.of(1); + + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + // @formatter:off + http + .authorizeExchange((exchanges) -> exchanges.anyExchange().authenticated()) + .formLogin(Customizer.withDefaults()) + .sessionManagement((sessionManagement) -> sessionManagement + .concurrentSessions((concurrentSessions) -> concurrentSessions + .maximumSessions(sessionLimit) + ) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(Config.class) + static class ConcurrentSessionsFormLoginOverrideAuthenticationSuccessHandlerConfig { + + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + // @formatter:off + http + .authorizeExchange((exchanges) -> exchanges.anyExchange().authenticated()) + .formLogin((login) -> login + .authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler("/")) + ) + .sessionManagement((sessionManagement) -> sessionManagement + .concurrentSessions((concurrentSessions) -> concurrentSessions + .maximumSessions(SessionLimit.of(1)) + .maximumSessionsExceededHandler(new PreventLoginServerMaximumSessionsExceededHandler()) + ) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(Config.class) + static class ConcurrentSessionsHttpBasicWithWebSessionMaxSessionPreventsLoginConfig { + + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + // @formatter:off + http + .authorizeExchange((exchanges) -> exchanges.anyExchange().authenticated()) + .httpBasic((basic) -> basic + .securityContextRepository(new WebSessionServerSecurityContextRepository()) + ) + .sessionManagement((sessionManagement) -> sessionManagement + .concurrentSessions((concurrentSessions) -> concurrentSessions + .maximumSessions(SessionLimit.of(1)) + .maximumSessionsExceededHandler(new PreventLoginServerMaximumSessionsExceededHandler()) + ) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @Import({ ReactiveAuthenticationTestConfiguration.class, DefaultController.class }) + static class Config { + + @Bean(WebHttpHandlerBuilder.WEB_SESSION_MANAGER_BEAN_NAME) + DefaultWebSessionManager webSessionManager() { + return new DefaultWebSessionManager(); + } + + @Bean + ReactiveSessionRegistry reactiveSessionRegistry(DefaultWebSessionManager webSessionManager) { + return new WebSessionStoreReactiveSessionRegistry(webSessionManager.getSessionStore()); + } + + } + + @RestController + static class DefaultController { + + @GetMapping("/") + String index() { + return "ok"; + } + + } + +} diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDslTests.kt new file mode 100644 index 0000000000..eda22ac6be --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDslTests.kt @@ -0,0 +1,283 @@ +/* + * Copyright 2002-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 org.springframework.security.config.web.server + +import org.junit.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.http.ResponseCookie +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.config.users.ReactiveAuthenticationTestConfiguration +import org.springframework.security.core.session.ReactiveSessionRegistry +import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.authentication.InvalidateLeastUsedServerMaximumSessionsExceededHandler +import org.springframework.security.web.server.authentication.PreventLoginServerMaximumSessionsExceededHandler +import org.springframework.security.web.server.authentication.SessionLimit +import org.springframework.security.web.session.WebSessionStoreReactiveSessionRegistry +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.reactive.config.EnableWebFlux +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.server.adapter.WebHttpHandlerBuilder +import org.springframework.web.server.session.DefaultWebSessionManager +import reactor.core.publisher.Mono + +/** + * Tests for [ServerSessionManagementDsl] + * + * @author Marcus da Coregio + */ +@ExtendWith(SpringTestContextExtension::class) +class ServerSessionManagementDslTests { + + @JvmField + val spring = SpringTestContext(this) + + private lateinit var client: WebTestClient + + @Autowired + fun setup(context: ApplicationContext) { + this.client = WebTestClient + .bindToApplicationContext(context) + .configureClient() + .build() + } + + @Test + fun `login when max sessions prevent login then second login fails`() { + this.spring.register(ConcurrentSessionsMaxSessionPreventsLoginTrueConfig::class.java).autowire() + + val data: MultiValueMap = LinkedMultiValueMap() + data.add("username", "user") + data.add("password", "password") + + val firstLoginSessionCookie = loginReturningCookie(data) + + // second login should fail + this.client.mutateWith(SecurityMockServerConfigurers.csrf()) + .post() + .uri("/login") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromFormData(data)) + .exchange() + .expectHeader() + .location("/login?error") + + // first login should still be valid + this.client.mutateWith(SecurityMockServerConfigurers.csrf()) + .get() + .uri("/") + .cookie(firstLoginSessionCookie!!.name, firstLoginSessionCookie.value) + .exchange() + .expectStatus() + .isOk() + } + + @Test + fun `login when max sessions does not prevent login then seconds login succeeds and first session is invalidated`() { + ConcurrentSessionsMaxSessionPreventsLoginFalseConfig.maxSessions = 1 + this.spring.register(SessionManagementSpecTests.ConcurrentSessionsMaxSessionPreventsLoginFalseConfig::class.java) + .autowire() + + val data: MultiValueMap = LinkedMultiValueMap() + data.add("username", "user") + data.add("password", "password") + + val firstLoginSessionCookie = loginReturningCookie(data) + val secondLoginSessionCookie = loginReturningCookie(data) + + // first login should not be valid + this.client.get() + .uri("/") + .cookie(firstLoginSessionCookie!!.name, firstLoginSessionCookie.value) + .exchange() + .expectStatus() + .isFound() + .expectHeader() + .location("/login") + + // second login should be valid + this.client.get() + .uri("/") + .cookie(secondLoginSessionCookie!!.name, secondLoginSessionCookie.value) + .exchange() + .expectStatus() + .isOk() + } + + @Test + fun `login when max sessions does not prevent login then least recently used session is invalidated`() { + ConcurrentSessionsMaxSessionPreventsLoginFalseConfig.maxSessions = 2 + this.spring.register(ConcurrentSessionsMaxSessionPreventsLoginFalseConfig::class.java).autowire() + val data: MultiValueMap = LinkedMultiValueMap() + data.add("username", "user") + data.add("password", "password") + val firstLoginSessionCookie = loginReturningCookie(data) + val secondLoginSessionCookie = loginReturningCookie(data) + + // update last access time for first request + this.client.get() + .uri("/") + .cookie(firstLoginSessionCookie!!.name, firstLoginSessionCookie.value) + .exchange() + .expectStatus() + .isOk() + val thirdLoginSessionCookie = loginReturningCookie(data) + + // second login should be invalid, it is the least recently used session + this.client.get() + .uri("/") + .cookie(secondLoginSessionCookie!!.name, secondLoginSessionCookie.value) + .exchange() + .expectStatus() + .isFound() + .expectHeader() + .location("/login") + + // first login should be valid + this.client.get() + .uri("/") + .cookie(firstLoginSessionCookie.name, firstLoginSessionCookie.value) + .exchange() + .expectStatus() + .isOk() + + // third login should be valid + this.client.get() + .uri("/") + .cookie(thirdLoginSessionCookie!!.name, thirdLoginSessionCookie.value) + .exchange() + .expectStatus() + .isOk() + } + + private fun loginReturningCookie(data: MultiValueMap): ResponseCookie? { + return login(data).expectCookie() + .exists("SESSION") + .returnResult(Void::class.java) + .responseCookies + .getFirst("SESSION") + } + + private fun login(data: MultiValueMap): WebTestClient.ResponseSpec { + return client.mutateWith(SecurityMockServerConfigurers.csrf()) + .post() + .uri("/login") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromFormData(data)) + .exchange() + .expectStatus() + .is3xxRedirection() + .expectHeader() + .location("/") + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(Config::class) + open class ConcurrentSessionsMaxSessionPreventsLoginFalseConfig { + + companion object { + var maxSessions = 1 + } + + @Bean + open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + formLogin { } + sessionManagement { + sessionConcurrency { + maximumSessions = SessionLimit.of(maxSessions) + maximumSessionsExceededHandler = InvalidateLeastUsedServerMaximumSessionsExceededHandler() + } + } + } + } + + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(Config::class) + open class ConcurrentSessionsMaxSessionPreventsLoginTrueConfig { + + @Bean + open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + formLogin { } + sessionManagement { + sessionConcurrency { + maximumSessions = SessionLimit.of(1) + maximumSessionsExceededHandler = + PreventLoginServerMaximumSessionsExceededHandler() + } + } + } + } + + } + + @Configuration + @Import( + ReactiveAuthenticationTestConfiguration::class, + DefaultController::class + ) + open class Config { + + @Bean(WebHttpHandlerBuilder.WEB_SESSION_MANAGER_BEAN_NAME) + open fun webSessionManager(): DefaultWebSessionManager { + return DefaultWebSessionManager() + } + + @Bean + open fun reactiveSessionRegistry(webSessionManager: DefaultWebSessionManager): ReactiveSessionRegistry { + return WebSessionStoreReactiveSessionRegistry(webSessionManager.sessionStore) + } + + } + + @RestController + open class DefaultController { + + @GetMapping("/") + fun index(): String { + return "ok" + } + + } + + +} diff --git a/core/src/main/java/org/springframework/security/core/session/InMemoryReactiveSessionRegistry.java b/core/src/main/java/org/springframework/security/core/session/InMemoryReactiveSessionRegistry.java new file mode 100644 index 0000000000..046a039505 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/session/InMemoryReactiveSessionRegistry.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-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 org.springframework.security.core.session; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArraySet; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Provides an in-memory implementation of {@link ReactiveSessionRegistry}. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public class InMemoryReactiveSessionRegistry implements ReactiveSessionRegistry { + + private final ConcurrentMap> sessionIdsByPrincipal; + + private final Map sessionById; + + public InMemoryReactiveSessionRegistry() { + this.sessionIdsByPrincipal = new ConcurrentHashMap<>(); + this.sessionById = new ConcurrentHashMap<>(); + } + + public InMemoryReactiveSessionRegistry(ConcurrentMap> sessionIdsByPrincipal, + Map sessionById) { + this.sessionIdsByPrincipal = sessionIdsByPrincipal; + this.sessionById = sessionById; + } + + @Override + public Flux getAllSessions(Object principal, boolean includeExpiredSessions) { + return Flux.fromIterable(this.sessionIdsByPrincipal.getOrDefault(principal, Collections.emptySet())) + .map(this.sessionById::get) + .filter((sessionInformation) -> includeExpiredSessions || !sessionInformation.isExpired()); + } + + @Override + public Mono saveSessionInformation(ReactiveSessionInformation information) { + this.sessionById.put(information.getSessionId(), information); + this.sessionIdsByPrincipal.computeIfAbsent(information.getPrincipal(), (key) -> new CopyOnWriteArraySet<>()) + .add(information.getSessionId()); + return Mono.empty(); + } + + @Override + public Mono getSessionInformation(String sessionId) { + return Mono.justOrEmpty(this.sessionById.get(sessionId)); + } + + @Override + public Mono removeSessionInformation(String sessionId) { + return getSessionInformation(sessionId).doOnNext((sessionInformation) -> { + this.sessionById.remove(sessionId); + Set sessionsUsedByPrincipal = this.sessionIdsByPrincipal.get(sessionInformation.getPrincipal()); + if (sessionsUsedByPrincipal != null) { + sessionsUsedByPrincipal.remove(sessionId); + if (sessionsUsedByPrincipal.isEmpty()) { + this.sessionIdsByPrincipal.remove(sessionInformation.getPrincipal()); + } + } + }); + } + + @Override + public Mono updateLastAccessTime(String sessionId) { + ReactiveSessionInformation session = this.sessionById.get(sessionId); + if (session != null) { + return session.refreshLastRequest().thenReturn(session); + } + return Mono.empty(); + } + +} diff --git a/core/src/main/java/org/springframework/security/core/session/ReactiveSessionInformation.java b/core/src/main/java/org/springframework/security/core/session/ReactiveSessionInformation.java new file mode 100644 index 0000000000..6ebfdb9141 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/session/ReactiveSessionInformation.java @@ -0,0 +1,83 @@ +/* + * Copyright 2002-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 org.springframework.security.core.session; + +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; + +import reactor.core.publisher.Mono; + +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.util.Assert; + +public class ReactiveSessionInformation implements Serializable { + + @Serial + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private Instant lastAccessTime; + + private final Object principal; + + private final String sessionId; + + private boolean expired = false; + + public ReactiveSessionInformation(Object principal, String sessionId, Instant lastAccessTime) { + Assert.notNull(principal, "principal cannot be null"); + Assert.hasText(sessionId, "sessionId cannot be null"); + Assert.notNull(lastAccessTime, "lastAccessTime cannot be null"); + this.principal = principal; + this.sessionId = sessionId; + this.lastAccessTime = lastAccessTime; + } + + public ReactiveSessionInformation withSessionId(String sessionId) { + return new ReactiveSessionInformation(this.principal, sessionId, this.lastAccessTime); + } + + public Mono invalidate() { + return Mono.fromRunnable(() -> this.expired = true); + } + + public Mono refreshLastRequest() { + this.lastAccessTime = Instant.now(); + return Mono.empty(); + } + + public Instant getLastAccessTime() { + return this.lastAccessTime; + } + + public Object getPrincipal() { + return this.principal; + } + + public String getSessionId() { + return this.sessionId; + } + + public boolean isExpired() { + return this.expired; + } + + public void setLastAccessTime(Instant lastAccessTime) { + this.lastAccessTime = lastAccessTime; + } + +} diff --git a/core/src/main/java/org/springframework/security/core/session/ReactiveSessionRegistry.java b/core/src/main/java/org/springframework/security/core/session/ReactiveSessionRegistry.java new file mode 100644 index 0000000000..b5da453164 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/session/ReactiveSessionRegistry.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-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 org.springframework.security.core.session; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Maintains a registry of {@link ReactiveSessionInformation} instances. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public interface ReactiveSessionRegistry { + + /** + * Gets all the known {@link ReactiveSessionInformation} instances for the specified + * principal. + * @param principal the principal + * @return the {@link ReactiveSessionInformation} instances associated with the + * principal + */ + Flux getAllSessions(Object principal, boolean includeExpiredSessions); + + /** + * Saves the {@link ReactiveSessionInformation} + * @param information the {@link ReactiveSessionInformation} to save + * @return a {@link Mono} that completes when the session is saved + */ + Mono saveSessionInformation(ReactiveSessionInformation information); + + /** + * Gets the {@link ReactiveSessionInformation} for the specified session identifier. + * @param sessionId the session identifier + * @return the {@link ReactiveSessionInformation} for the session. + */ + Mono getSessionInformation(String sessionId); + + /** + * Removes the specified session from the registry. + * @param sessionId the session identifier + * @return a {@link Mono} that completes when the session is removed + */ + Mono removeSessionInformation(String sessionId); + + /** + * Updates the last accessed time of the {@link ReactiveSessionInformation} + * @param sessionId the session identifier + * @return a {@link Mono} that completes when the session is updated + */ + Mono updateLastAccessTime(String sessionId); + +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index bc29759352..9dd4779406 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -129,6 +129,8 @@ ** Authentication *** xref:reactive/authentication/x509.adoc[X.509 Authentication] *** xref:reactive/authentication/logout.adoc[Logout] +*** Session Management +**** xref:reactive/authentication/concurrent-sessions-control.adoc[Concurrent Sessions Control] ** Authorization *** xref:reactive/authorization/authorize-http-requests.adoc[Authorize HTTP Requests] *** xref:reactive/authorization/method.adoc[EnableReactiveMethodSecurity] diff --git a/docs/modules/ROOT/pages/reactive/authentication/concurrent-sessions-control.adoc b/docs/modules/ROOT/pages/reactive/authentication/concurrent-sessions-control.adoc new file mode 100644 index 0000000000..480c269381 --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/authentication/concurrent-sessions-control.adoc @@ -0,0 +1,465 @@ +[[reactive-concurrent-sessions-control]] += Concurrent Sessions Control + +Similar to xref:servlet/authentication/session-management.adoc#ns-concurrent-sessions[Servlet's Concurrent Sessions Control], Spring Security also provides support to limit the number of concurrent sessions a user can have in a Reactive application. + +When you set up Concurrent Sessions Control in Spring Security, it monitors authentications carried out through Form Login, xref:reactive/oauth2/login/index.adoc[OAuth 2.0 Login], and HTTP Basic authentication by hooking into the way those authentication mechanisms handle authentication success. +More specifically, the session management DSL will add the {security-api-url}org/springframework/security/web/server/authentication/ConcurrentSessionControlServerAuthenticationSuccessHandler.html[ConcurrentSessionControlServerAuthenticationSuccessHandler] and the {security-api-url}org/springframework/security/web/server/authentication/RegisterSessionServerAuthenticationSuccessHandler.html[RegisterSessionServerAuthenticationSuccessHandler] to the list of `ServerAuthenticationSuccessHandler` used by the authentication filter. + +The following sections contains examples of how to configure Concurrent Sessions Control. + +* <> +* <> +* <> +* <> +* <> + +[[reactive-concurrent-sessions-control-limit]] +== Limiting Concurrent Sessions + +By default, Spring Security will allow any number of concurrent sessions for a user. +To limit the number of concurrent sessions, you can use the `maximumSessions` DSL method: + +.Configuring one session for any user +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + // ... + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions(SessionLimit.of(1)) + ); + return http.build(); +} + +@Bean +ReactiveSessionRegistry reactiveSessionRegistry(WebSessionManager webSessionManager) { + return new WebSessionStoreReactiveSessionRegistry(((DefaultWebSessionManager) webSessionManager).getSessionStore()); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + sessionManagement { + sessionConcurrency { + maximumSessions = SessionLimit.of(1) + } + } + } +} +@Bean +open fun reactiveSessionRegistry(webSessionManager: WebSessionManager): ReactiveSessionRegistry { + return WebSessionStoreReactiveSessionRegistry((webSessionManager as DefaultWebSessionManager).sessionStore) +} +---- +====== + +The above configuration allows one session for any user. +Similarly, you can also allow unlimited sessions by using the `SessionLimit#UNLIMITED` constant: + +.Configuring unlimited sessions +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + // ... + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions(SessionLimit.UNLIMITED)) + ); + return http.build(); +} + +@Bean +ReactiveSessionRegistry reactiveSessionRegistry(WebSessionManager webSessionManager) { + return new WebSessionStoreReactiveSessionRegistry(((DefaultWebSessionManager) webSessionManager).getSessionStore()); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + sessionManagement { + sessionConcurrency { + maximumSessions = SessionLimit.UNLIMITED + } + } + } +} +@Bean +open fun reactiveSessionRegistry(webSessionManager: WebSessionManager): ReactiveSessionRegistry { + return WebSessionStoreReactiveSessionRegistry((webSessionManager as DefaultWebSessionManager).sessionStore) +} +---- +====== + +Since the `maximumSessions` method accepts a `SessionLimit` interface, which in turn extends `Function>`, you can have a more complex logic to determine the maximum number of sessions based on the user's authentication: + +.Configuring maximumSessions based on `Authentication` +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + // ... + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions(maxSessions())) + ); + return http.build(); +} + +private SessionLimit maxSessions() { + return (authentication) -> { + if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_UNLIMITED_SESSIONS"))) { + return Mono.empty(); // allow unlimited sessions for users with ROLE_UNLIMITED_SESSIONS + } + if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) { + return Mono.just(2); // allow two sessions for admins + } + return Mono.just(1); // allow one session for every other user + }; +} + +@Bean +ReactiveSessionRegistry reactiveSessionRegistry(WebSessionManager webSessionManager) { + return new WebSessionStoreReactiveSessionRegistry(((DefaultWebSessionManager) webSessionManager).getSessionStore()); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + sessionManagement { + sessionConcurrency { + maximumSessions = maxSessions() + } + } + } +} + +fun maxSessions(): SessionLimit { + return { authentication -> + if (authentication.authorities.contains(SimpleGrantedAuthority("ROLE_UNLIMITED_SESSIONS"))) Mono.empty + if (authentication.authorities.contains(SimpleGrantedAuthority("ROLE_ADMIN"))) Mono.just(2) + Mono.just(1) + } +} + +@Bean +open fun reactiveSessionRegistry(webSessionManager: WebSessionManager): ReactiveSessionRegistry { + return WebSessionStoreReactiveSessionRegistry((webSessionManager as DefaultWebSessionManager).sessionStore) +} +---- +====== + +When the maximum number of sessions is exceeded, by default, the least recently used session(s) will be expired. +If you want to change that behavior, you can <>. + +[[concurrent-sessions-control-custom-strategy]] +== Handling Maximum Number of Sessions Exceeded + +By default, when the maximum number of sessions is exceeded, the least recently used session(s) will be expired by using the {security-api-url}org/springframework/security/web/server/authentication/session/InvalidateLeastUsedMaximumSessionsExceededHandler.html[InvalidateLeastUsedMaximumSessionsExceededHandler]. +Spring Security also provides another implementation that prevents the user from creating new sessions by using the {security-api-url}org/springframework/security/web/server/authentication/session/PreventLoginMaximumSessionsExceededHandler.html[PreventLoginMaximumSessionsExceededHandler]. +If you want to use your own strategy, you can provide a different implementation of {security-api-url}org/springframework/security/web/server/authentication/session/ServerMaximumSessionsExceededHandler.html[ServerMaximumSessionsExceededHandler]. + +.Configuring maximumSessionsExceededHandler +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + // ... + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions(SessionLimit.of(1)) + .maximumSessionsExceededHandler(new PreventLoginMaximumSessionsExceededHandler()) + ) + ); + return http.build(); +} + +@Bean +ReactiveSessionRegistry reactiveSessionRegistry(WebSessionManager webSessionManager) { + return new WebSessionStoreReactiveSessionRegistry(((DefaultWebSessionManager) webSessionManager).getSessionStore()); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + sessionManagement { + sessionConcurrency { + maximumSessions = SessionLimit.of(1) + maximumSessionsExceededHandler = PreventLoginMaximumSessionsExceededHandler() + } + } + } +} + +@Bean +open fun reactiveSessionRegistry(webSessionManager: WebSessionManager): ReactiveSessionRegistry { + return WebSessionStoreReactiveSessionRegistry((webSessionManager as DefaultWebSessionManager).sessionStore) +} +---- +====== + +[[reactive-concurrent-sessions-control-specify-session-registry]] +== Specifying a `ReactiveSessionRegistry` + +In order to keep track of the user's sessions, Spring Security uses a {security-api-url}org/springframework/security/core/session/ReactiveSessionRegistry.html[ReactiveSessionRegistry], and, every time a user logs in, their session information is saved. +Typically, in a Spring WebFlux application, you will use the {security-api-url}/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistry.html[WebSessionStoreReactiveSessionRegistry] which makes sure that the `WebSession` is invalidated whenever the `ReactiveSessionInformation` is invalidated. + +Spring Security ships with {security-api-url}/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistry.html[WebSessionStoreReactiveSessionRegistry] and {security-api-url}org/springframework/security/core/session/InMemoryReactiveSessionRegistry.html[InMemoryReactiveSessionRegistry] implementations of `ReactiveSessionRegistry`. + +[NOTE] +==== +When creating the `WebSessionStoreReactiveSessionRegistry`, you need to provide the `WebSessionStore` that is being used by your application. +If you are using Spring WebFlux, you can use the `WebSessionManager` bean (which is usually an instance of `DefaultWebSessionManager`) to get the `WebSessionStore`. +==== + +To specify a `ReactiveSessionRegistry` implementation you can either declare it as a bean: + +.ReactiveSessionRegistry as a Bean +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + // ... + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions(SessionLimit.of(1)) + ) + ); + return http.build(); +} + +@Bean +ReactiveSessionRegistry reactiveSessionRegistry() { + return new InMemoryReactiveSessionRegistry(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + sessionManagement { + sessionConcurrency { + maximumSessions = SessionLimit.of(1) + } + } + } +} + +@Bean +open fun reactiveSessionRegistry(): ReactiveSessionRegistry { + return InMemoryReactiveSessionRegistry() +} +---- +====== + +or you can use the `sessionRegistry` DSL method: + +.ReactiveSessionRegistry using sessionRegistry DSL method +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + // ... + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions(SessionLimit.of(1)) + .sessionRegistry(new InMemoryReactiveSessionRegistry()) + ) + ); + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + sessionManagement { + sessionConcurrency { + maximumSessions = SessionLimit.of(1) + sessionRegistry = InMemoryReactiveSessionRegistry() + } + } + } +} +---- +====== + +[[reactive-concurrent-sessions-control-manually-invalidating-sessions]] +== Invalidating Registered User's Sessions + +At times, it is handy to be able to invalidate all or some of a user's sessions. +For example, when a user changes their password, you may want to invalidate all of their sessions so that they are forced to log in again. +To do that, you can use the `ReactiveSessionRegistry` bean to retrieve all the user's sessions and then invalidate them: + +.Using ReactiveSessionRegistry to invalidate sessions manually +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +public class SessionControl { + private final ReactiveSessionRegistry reactiveSessionRegistry; + + public SessionControl(ReactiveSessionRegistry reactiveSessionRegistry) { + this.reactiveSessionRegistry = reactiveSessionRegistry; + } + + public Mono invalidateSessions(String username) { + return this.reactiveSessionRegistry.getAllSessions(username) + .flatMap(ReactiveSessionInformation::invalidate) + .then(); + } +} +---- +====== + +[[disabling-for-authentication-filters]] +== Disabling It for Some Authentication Filters + +By default, Concurrent Sessions Control will be configured automatically for Form Login, OAuth 2.0 Login, and HTTP Basic authentication as long as they do not specify an `ServerAuthenticationSuccessHandler` themselves. +For example, the following configuration will disable Concurrent Sessions Control for Form Login: + +.Disabling Concurrent Sessions Control for Form Login +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + // ... + .formLogin((login) -> login + .authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler("/")) + ) + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions(SessionLimit.of(1)) + ) + ); + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun springSecurity(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + formLogin { + authenticationSuccessHandler = RedirectServerAuthenticationSuccessHandler("/") + } + sessionManagement { + sessionConcurrency { + maximumSessions = SessionLimit.of(1) + } + } + } +} +---- +====== + +=== Adding Additional Success Handlers Without Disabling Concurrent Sessions Control + +You can also include additional `ServerAuthenticationSuccessHandler` instances to the list of handlers used by the authentication filter without disabling Concurrent Sessions Control. +To do that you can use the `authenticationSuccessHandler(Consumer>)` method: + +.Adding additional handlers +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + // ... + .formLogin((login) -> login + .authenticationSuccessHandler((handlers) -> handlers.add(new MyAuthenticationSuccessHandler())) + ) + .sessionManagement((sessions) -> sessions + .concurrentSessions((concurrency) -> concurrency + .maximumSessions(SessionLimit.of(1)) + ) + ); + return http.build(); +} +---- +====== + +[[concurrent-sessions-control-sample]] +== Checking a Sample Application + +You can check the {gh-samples-url}/reactive/webflux/java/session-management/maximum-sessions[sample application here]. diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index b7751f2f0b..f163563774 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -3,3 +3,7 @@ Spring Security 6.3 provides a number of new features. Below are the highlights of the release. + +== Configuration + +- https://github.com/spring-projects/spring-security/issues/6192[gh-6192] - xref:reactive/authentication/concurrent-sessions-control.adoc[docs] Add Concurrent Sessions Control on WebFlux diff --git a/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java b/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java index b4159f8e58..db1650b3a9 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java +++ b/web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-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. @@ -19,12 +19,15 @@ package org.springframework.security.web.authentication.session; import org.springframework.security.core.AuthenticationException; /** - * Thrown by an SessionAuthenticationStrategy to indicate that an authentication - * object is not valid for the current session, typically because the same user has - * exceeded the number of sessions they are allowed to have concurrently. + * Thrown by an {@link SessionAuthenticationStrategy} or + * {@link ServerSessionAuthenticationStrategy} to indicate that an authentication object + * is not valid for the current session, typically because the same user has exceeded the + * number of sessions they are allowed to have concurrently. * * @author Luke Taylor * @since 3.0 + * @see SessionAuthenticationStrategy + * @see ServerSessionAuthenticationStrategy */ public class SessionAuthenticationException extends AuthenticationException { diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/ConcurrentSessionControlServerAuthenticationSuccessHandler.java b/web/src/main/java/org/springframework/security/web/server/authentication/ConcurrentSessionControlServerAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..d65951719f --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/ConcurrentSessionControlServerAuthenticationSuccessHandler.java @@ -0,0 +1,111 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication; + +import java.util.List; + +import reactor.core.publisher.Mono; +import reactor.util.function.Tuples; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.util.Assert; +import org.springframework.web.server.WebSession; + +/** + * Controls the number of sessions a user can have concurrently authenticated in an + * application. It also allows for customizing behaviour when an authentication attempt is + * made while the user already has the maximum number of sessions open. By default, it + * allows a maximum of 1 session per user, if the maximum is exceeded, the user's least + * recently used session(s) will be expired. + * + * @author Marcus da Coregio + * @since 6.3 + * @see ServerMaximumSessionsExceededHandler + * @see RegisterSessionServerAuthenticationSuccessHandler + */ +public final class ConcurrentSessionControlServerAuthenticationSuccessHandler + implements ServerAuthenticationSuccessHandler { + + private final ReactiveSessionRegistry sessionRegistry; + + private SessionLimit sessionLimit = SessionLimit.of(1); + + private ServerMaximumSessionsExceededHandler maximumSessionsExceededHandler = new InvalidateLeastUsedServerMaximumSessionsExceededHandler(); + + public ConcurrentSessionControlServerAuthenticationSuccessHandler(ReactiveSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) { + return this.sessionLimit.apply(authentication) + .flatMap((maxSessions) -> handleConcurrency(exchange, authentication, maxSessions)); + } + + private Mono handleConcurrency(WebFilterExchange exchange, Authentication authentication, + Integer maximumSessions) { + return this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false) + .collectList() + .flatMap((registeredSessions) -> exchange.getExchange() + .getSession() + .map((currentSession) -> Tuples.of(currentSession, registeredSessions))) + .flatMap((sessionTuple) -> { + WebSession currentSession = sessionTuple.getT1(); + List registeredSessions = sessionTuple.getT2(); + int registeredSessionsCount = registeredSessions.size(); + if (registeredSessionsCount < maximumSessions) { + return Mono.empty(); + } + if (registeredSessionsCount == maximumSessions) { + for (ReactiveSessionInformation registeredSession : registeredSessions) { + if (registeredSession.getSessionId().equals(currentSession.getId())) { + return Mono.empty(); + } + } + } + return this.maximumSessionsExceededHandler + .handle(new MaximumSessionsContext(authentication, registeredSessions, maximumSessions)); + }); + } + + /** + * Sets the strategy used to resolve the maximum number of sessions that are allowed + * for a specific {@link Authentication}. By default, it returns {@code 1} for any + * authentication. + * @param sessionLimit the {@link SessionLimit} to use + */ + public void setSessionLimit(SessionLimit sessionLimit) { + Assert.notNull(sessionLimit, "sessionLimit cannot be null"); + this.sessionLimit = sessionLimit; + } + + /** + * Sets the {@link ServerMaximumSessionsExceededHandler} to use. The default is + * {@link InvalidateLeastUsedServerMaximumSessionsExceededHandler}. + * @param maximumSessionsExceededHandler the + * {@link ServerMaximumSessionsExceededHandler} to use + */ + public void setMaximumSessionsExceededHandler(ServerMaximumSessionsExceededHandler maximumSessionsExceededHandler) { + Assert.notNull(maximumSessionsExceededHandler, "maximumSessionsExceededHandler cannot be null"); + this.maximumSessionsExceededHandler = maximumSessionsExceededHandler; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandler.java b/web/src/main/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandler.java index 851e80620a..d74ffed43c 100644 --- a/web/src/main/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandler.java +++ b/web/src/main/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -42,6 +42,16 @@ public class DelegatingServerAuthenticationSuccessHandler implements ServerAuthe this.delegates = Arrays.asList(delegates); } + /** + * Creates a new instance with the provided list of delegates + * @param delegates the {@link List} of {@link ServerAuthenticationSuccessHandler} + * @since 6.3 + */ + public DelegatingServerAuthenticationSuccessHandler(List delegates) { + Assert.notEmpty(delegates, "delegates cannot be null or empty"); + this.delegates = delegates; + } + @Override public Mono onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) { return Flux.fromIterable(this.delegates) diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/InvalidateLeastUsedServerMaximumSessionsExceededHandler.java b/web/src/main/java/org/springframework/security/web/server/authentication/InvalidateLeastUsedServerMaximumSessionsExceededHandler.java new file mode 100644 index 0000000000..efc4a3b674 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/InvalidateLeastUsedServerMaximumSessionsExceededHandler.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.core.session.ReactiveSessionInformation; + +/** + * Implementation of {@link ServerMaximumSessionsExceededHandler} that invalidates the + * least recently used session(s). It only invalidates the amount of sessions that exceed + * the maximum allowed. For example, if the maximum was exceeded by 1, only the least + * recently used session will be invalidated. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public final class InvalidateLeastUsedServerMaximumSessionsExceededHandler + implements ServerMaximumSessionsExceededHandler { + + private final Log logger = LogFactory.getLog(getClass()); + + @Override + public Mono handle(MaximumSessionsContext context) { + List sessions = new ArrayList<>(context.getSessions()); + sessions.sort(Comparator.comparing(ReactiveSessionInformation::getLastAccessTime)); + int maximumSessionsExceededBy = sessions.size() - context.getMaximumSessionsAllowed() + 1; + List leastRecentlyUsedSessionsToInvalidate = sessions.subList(0, + maximumSessionsExceededBy); + + return Flux.fromIterable(leastRecentlyUsedSessionsToInvalidate) + .doOnComplete(() -> this.logger + .debug(LogMessage.format("Invalidated %d least recently used sessions for authentication %s", + leastRecentlyUsedSessionsToInvalidate.size(), context.getAuthentication().getName()))) + .flatMap(ReactiveSessionInformation::invalidate) + .then(); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/MaximumSessionsContext.java b/web/src/main/java/org/springframework/security/web/server/authentication/MaximumSessionsContext.java new file mode 100644 index 0000000000..0875051b78 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/MaximumSessionsContext.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication; + +import java.util.List; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.ReactiveSessionInformation; + +public final class MaximumSessionsContext { + + private final Authentication authentication; + + private final List sessions; + + private final int maximumSessionsAllowed; + + public MaximumSessionsContext(Authentication authentication, List sessions, + int maximumSessionsAllowed) { + this.authentication = authentication; + this.sessions = sessions; + this.maximumSessionsAllowed = maximumSessionsAllowed; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + public List getSessions() { + return this.sessions; + } + + public int getMaximumSessionsAllowed() { + return this.maximumSessionsAllowed; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/PreventLoginServerMaximumSessionsExceededHandler.java b/web/src/main/java/org/springframework/security/web/server/authentication/PreventLoginServerMaximumSessionsExceededHandler.java new file mode 100644 index 0000000000..a98f8795e6 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/PreventLoginServerMaximumSessionsExceededHandler.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication; + +import reactor.core.publisher.Mono; + +import org.springframework.security.web.authentication.session.SessionAuthenticationException; + +/** + * Returns a {@link Mono} that terminates with {@link SessionAuthenticationException} when + * the maximum number of sessions for a user has been reached. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public final class PreventLoginServerMaximumSessionsExceededHandler implements ServerMaximumSessionsExceededHandler { + + @Override + public Mono handle(MaximumSessionsContext context) { + return Mono + .error(new SessionAuthenticationException("Maximum sessions of " + context.getMaximumSessionsAllowed() + + " for authentication '" + context.getAuthentication().getName() + "' exceeded")); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/RegisterSessionServerAuthenticationSuccessHandler.java b/web/src/main/java/org/springframework/security/web/server/authentication/RegisterSessionServerAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..bc086e23cb --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/RegisterSessionServerAuthenticationSuccessHandler.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication; + +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.util.Assert; + +/** + * An implementation of {@link ServerAuthenticationSuccessHandler} that will register a + * {@link ReactiveSessionInformation} with the provided {@link ReactiveSessionRegistry}. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public final class RegisterSessionServerAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { + + private final ReactiveSessionRegistry sessionRegistry; + + public RegisterSessionServerAuthenticationSuccessHandler(ReactiveSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) { + return exchange.getExchange() + .getSession() + .map((session) -> new ReactiveSessionInformation(authentication.getPrincipal(), session.getId(), + session.getLastAccessTime())) + .flatMap(this.sessionRegistry::saveSessionInformation); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/ServerMaximumSessionsExceededHandler.java b/web/src/main/java/org/springframework/security/web/server/authentication/ServerMaximumSessionsExceededHandler.java new file mode 100644 index 0000000000..6b90cbcd10 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/ServerMaximumSessionsExceededHandler.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication; + +import reactor.core.publisher.Mono; + +/** + * Strategy for handling the scenario when the maximum number of sessions for a user has + * been reached. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public interface ServerMaximumSessionsExceededHandler { + + /** + * Handles the scenario when the maximum number of sessions for a user has been + * reached. + * @param context the context with information about the sessions and the user + * @return an empty {@link Mono} that completes when the handling is done + */ + Mono handle(MaximumSessionsContext context); + +} diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/SessionLimit.java b/web/src/main/java/org/springframework/security/web/server/authentication/SessionLimit.java new file mode 100644 index 0000000000..ce96937500 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/SessionLimit.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication; + +import java.util.function.Function; + +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; + +/** + * Represents the maximum number of sessions allowed. Use {@link #UNLIMITED} to indicate + * that there is no limit. + * + * @author Marcus da Coregio + * @since 6.3 + * @see ConcurrentSessionControlServerAuthenticationSuccessHandler + */ +public interface SessionLimit extends Function> { + + /** + * Represents unlimited sessions. This is just a shortcut to return + * {@link Mono#empty()} for any user. + */ + SessionLimit UNLIMITED = (authentication) -> Mono.empty(); + + /** + * 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) { + return (authentication) -> Mono.just(maxSessions); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistry.java b/web/src/main/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistry.java new file mode 100644 index 0000000000..5d8cd5cc4d --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistry.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-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 org.springframework.security.web.session; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.security.core.session.InMemoryReactiveSessionRegistry; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.util.Assert; +import org.springframework.web.server.WebSession; +import org.springframework.web.server.session.WebSessionStore; + +/** + * A {@link ReactiveSessionRegistry} implementation that uses a {@link WebSessionStore} to + * invalidate a {@link WebSession} when the {@link ReactiveSessionInformation} is + * invalidated. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public final class WebSessionStoreReactiveSessionRegistry implements ReactiveSessionRegistry { + + private final WebSessionStore webSessionStore; + + private ReactiveSessionRegistry sessionRegistry = new InMemoryReactiveSessionRegistry(); + + public WebSessionStoreReactiveSessionRegistry(WebSessionStore webSessionStore) { + Assert.notNull(webSessionStore, "webSessionStore cannot be null"); + this.webSessionStore = webSessionStore; + } + + @Override + public Flux getAllSessions(Object principal, boolean includeExpiredSessions) { + return this.sessionRegistry.getAllSessions(principal, includeExpiredSessions).map(WebSessionInformation::new); + } + + @Override + public Mono saveSessionInformation(ReactiveSessionInformation information) { + return this.sessionRegistry.saveSessionInformation(new WebSessionInformation(information)); + } + + @Override + public Mono getSessionInformation(String sessionId) { + return this.sessionRegistry.getSessionInformation(sessionId).map(WebSessionInformation::new); + } + + @Override + public Mono removeSessionInformation(String sessionId) { + return this.sessionRegistry.removeSessionInformation(sessionId).map(WebSessionInformation::new); + } + + @Override + public Mono updateLastAccessTime(String sessionId) { + return this.sessionRegistry.updateLastAccessTime(sessionId).map(WebSessionInformation::new); + } + + /** + * Sets the {@link ReactiveSessionRegistry} to use. + * @param sessionRegistry the {@link ReactiveSessionRegistry} to use. Cannot be null. + */ + public void setSessionRegistry(ReactiveSessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + final class WebSessionInformation extends ReactiveSessionInformation { + + WebSessionInformation(ReactiveSessionInformation sessionInformation) { + super(sessionInformation.getPrincipal(), sessionInformation.getSessionId(), + sessionInformation.getLastAccessTime()); + } + + @Override + public Mono invalidate() { + return WebSessionStoreReactiveSessionRegistry.this.webSessionStore.retrieveSession(getSessionId()) + .flatMap(WebSession::invalidate) + .then(Mono + .defer(() -> WebSessionStoreReactiveSessionRegistry.this.removeSessionInformation(getSessionId()))) + .then(Mono.defer(super::invalidate)); + } + + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandlerTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandlerTests.java index 75852dbccc..444e745276 100644 --- a/web/src/test/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandlerTests.java +++ b/web/src/test/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-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. @@ -17,6 +17,8 @@ package org.springframework.security.web.server.authentication; import java.time.Duration; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -74,9 +76,15 @@ public class DelegatingServerAuthenticationSuccessHandlerTests { } @Test - public void constructorWhenEmptyThenIllegalArgumentException() { - assertThatIllegalArgumentException().isThrownBy( - () -> new DelegatingServerAuthenticationSuccessHandler(new ServerAuthenticationSuccessHandler[0])); + public void constructorWhenNullListThenIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> new DelegatingServerAuthenticationSuccessHandler( + (List) null)); + } + + @Test + public void constructorWhenEmptyListThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new DelegatingServerAuthenticationSuccessHandler(Collections.emptyList())); } @Test diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/session/ConcurrentSessionControlServerAuthenticationSuccessHandlerTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/session/ConcurrentSessionControlServerAuthenticationSuccessHandlerTests.java new file mode 100644 index 0000000000..88c97e2e22 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authentication/session/ConcurrentSessionControlServerAuthenticationSuccessHandlerTests.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication.session; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.mock.web.server.MockWebSession; +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.ConcurrentSessionControlServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.MaximumSessionsContext; +import org.springframework.security.web.server.authentication.ServerMaximumSessionsExceededHandler; +import org.springframework.security.web.server.authentication.SessionLimit; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link ConcurrentSessionControlServerAuthenticationSuccessHandler}. + * + * @author Marcus da Coregio + */ +class ConcurrentSessionControlServerAuthenticationSuccessHandlerTests { + + private ConcurrentSessionControlServerAuthenticationSuccessHandler strategy; + + ReactiveSessionRegistry sessionRegistry = mock(); + + ServerWebExchange exchange = mock(); + + WebFilterChain chain = mock(); + + ServerMaximumSessionsExceededHandler handler = mock(); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(MaximumSessionsContext.class); + + @BeforeEach + void setup() { + given(this.exchange.getResponse()).willReturn(new MockServerHttpResponse()); + given(this.exchange.getRequest()).willReturn(MockServerHttpRequest.get("/").build()); + given(this.exchange.getSession()).willReturn(Mono.just(new MockWebSession())); + given(this.handler.handle(any())).willReturn(Mono.empty()); + this.strategy = new ConcurrentSessionControlServerAuthenticationSuccessHandler(this.sessionRegistry); + this.strategy.setMaximumSessionsExceededHandler(this.handler); + } + + @Test + void constructorWhenNullRegistryThenException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ConcurrentSessionControlServerAuthenticationSuccessHandler(null)) + .withMessage("sessionRegistry cannot be null"); + } + + @Test + void setMaximumSessionsForAuthenticationWhenNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.strategy.setSessionLimit(null)) + .withMessage("sessionLimit cannot be null"); + } + + @Test + void setMaximumSessionsExceededHandlerWhenNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.strategy.setMaximumSessionsExceededHandler(null)) + .withMessage("maximumSessionsExceededHandler cannot be null"); + } + + @Test + void onAuthenticationWhenSessionLimitIsUnlimitedThenDoNothing() { + ServerMaximumSessionsExceededHandler handler = mock(ServerMaximumSessionsExceededHandler.class); + this.strategy.setSessionLimit(SessionLimit.UNLIMITED); + this.strategy.setMaximumSessionsExceededHandler(handler); + this.strategy.onAuthenticationSuccess(null, TestAuthentication.authenticatedUser()).block(); + verifyNoInteractions(handler, this.sessionRegistry); + } + + @Test + void onAuthenticationWhenMaximumSessionsIsOneAndExceededThenHandlerIsCalled() { + Authentication authentication = TestAuthentication.authenticatedUser(); + List sessions = Arrays.asList(createSessionInformation("100"), + createSessionInformation("101")); + given(this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false)) + .willReturn(Flux.fromIterable(sessions)); + this.strategy.onAuthenticationSuccess(new WebFilterExchange(this.exchange, this.chain), authentication).block(); + verify(this.handler).handle(this.contextCaptor.capture()); + assertThat(this.contextCaptor.getValue().getMaximumSessionsAllowed()).isEqualTo(1); + assertThat(this.contextCaptor.getValue().getSessions()).isEqualTo(sessions); + assertThat(this.contextCaptor.getValue().getAuthentication()).isEqualTo(authentication); + } + + @Test + void onAuthenticationWhenMaximumSessionsIsGreaterThanOneAndExceededThenHandlerIsCalled() { + this.strategy.setSessionLimit(SessionLimit.of(5)); + Authentication authentication = TestAuthentication.authenticatedUser(); + List sessions = Arrays.asList(createSessionInformation("100"), + createSessionInformation("101"), createSessionInformation("102"), createSessionInformation("103"), + createSessionInformation("104")); + given(this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false)) + .willReturn(Flux.fromIterable(sessions)); + this.strategy.onAuthenticationSuccess(new WebFilterExchange(this.exchange, this.chain), authentication).block(); + verify(this.handler).handle(this.contextCaptor.capture()); + assertThat(this.contextCaptor.getValue().getMaximumSessionsAllowed()).isEqualTo(5); + assertThat(this.contextCaptor.getValue().getSessions()).isEqualTo(sessions); + assertThat(this.contextCaptor.getValue().getAuthentication()).isEqualTo(authentication); + } + + @Test + void onAuthenticationWhenMaximumSessionsForUsersAreDifferentThenHandlerIsCalledWhereNeeded() { + Authentication user = TestAuthentication.authenticatedUser(); + Authentication admin = TestAuthentication.authenticatedAdmin(); + this.strategy.setSessionLimit((authentication) -> { + if (authentication.equals(user)) { + return Mono.just(1); + } + return Mono.just(3); + }); + + List userSessions = Arrays.asList(createSessionInformation("100")); + List adminSessions = Arrays.asList(createSessionInformation("200"), + createSessionInformation("201")); + + given(this.sessionRegistry.getAllSessions(user.getPrincipal(), false)) + .willReturn(Flux.fromIterable(userSessions)); + given(this.sessionRegistry.getAllSessions(admin.getPrincipal(), false)) + .willReturn(Flux.fromIterable(adminSessions)); + + this.strategy.onAuthenticationSuccess(new WebFilterExchange(this.exchange, this.chain), user).block(); + this.strategy.onAuthenticationSuccess(new WebFilterExchange(this.exchange, this.chain), admin).block(); + verify(this.handler).handle(this.contextCaptor.capture()); + assertThat(this.contextCaptor.getValue().getMaximumSessionsAllowed()).isEqualTo(1); + assertThat(this.contextCaptor.getValue().getSessions()).isEqualTo(userSessions); + assertThat(this.contextCaptor.getValue().getAuthentication()).isEqualTo(user); + } + + private ReactiveSessionInformation createSessionInformation(String sessionId) { + return new ReactiveSessionInformation(sessionId, "principal", Instant.now()); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/session/InMemoryReactiveSessionRegistryTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/session/InMemoryReactiveSessionRegistryTests.java new file mode 100644 index 0000000000..ceca216a21 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authentication/session/InMemoryReactiveSessionRegistryTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication.session; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.InMemoryReactiveSessionRegistry; +import org.springframework.security.core.session.ReactiveSessionInformation; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link InMemoryReactiveSessionRegistry}. + */ +class InMemoryReactiveSessionRegistryTests { + + InMemoryReactiveSessionRegistry sessionRegistry = new InMemoryReactiveSessionRegistry(); + + Instant now = LocalDate.of(2023, 11, 21).atStartOfDay().toInstant(ZoneOffset.UTC); + + @Test + void saveWhenPrincipalThenRegisterPrincipalSession() { + Authentication authentication = TestAuthentication.authenticatedUser(); + ReactiveSessionInformation sessionInformation = new ReactiveSessionInformation(authentication.getPrincipal(), + "1234", this.now); + this.sessionRegistry.saveSessionInformation(sessionInformation).block(); + List principalSessions = this.sessionRegistry + .getAllSessions(authentication.getPrincipal(), false) + .collectList() + .block(); + assertThat(principalSessions).hasSize(1); + assertThat(this.sessionRegistry.getSessionInformation("1234").block()).isNotNull(); + } + + @Test + void getAllSessionsWhenMultipleSessionsThenReturnAll() { + Authentication authentication = TestAuthentication.authenticatedUser(); + ReactiveSessionInformation sessionInformation1 = new ReactiveSessionInformation(authentication.getPrincipal(), + "1234", this.now); + ReactiveSessionInformation sessionInformation2 = new ReactiveSessionInformation(authentication.getPrincipal(), + "4321", this.now); + ReactiveSessionInformation sessionInformation3 = new ReactiveSessionInformation(authentication.getPrincipal(), + "9876", this.now); + this.sessionRegistry.saveSessionInformation(sessionInformation1).block(); + this.sessionRegistry.saveSessionInformation(sessionInformation2).block(); + this.sessionRegistry.saveSessionInformation(sessionInformation3).block(); + List sessions = this.sessionRegistry + .getAllSessions(authentication.getPrincipal(), false) + .collectList() + .block(); + assertThat(sessions).hasSize(3); + assertThat(this.sessionRegistry.getSessionInformation("1234").block()).isNotNull(); + assertThat(this.sessionRegistry.getSessionInformation("4321").block()).isNotNull(); + assertThat(this.sessionRegistry.getSessionInformation("9876").block()).isNotNull(); + } + + @Test + void removeSessionInformationThenSessionIsRemoved() { + Authentication authentication = TestAuthentication.authenticatedUser(); + ReactiveSessionInformation sessionInformation = new ReactiveSessionInformation(authentication.getPrincipal(), + "1234", this.now); + this.sessionRegistry.saveSessionInformation(sessionInformation).block(); + this.sessionRegistry.removeSessionInformation("1234").block(); + List sessions = this.sessionRegistry.getAllSessions(authentication.getName(), false) + .collectList() + .block(); + assertThat(this.sessionRegistry.getSessionInformation("1234").block()).isNull(); + assertThat(sessions).isEmpty(); + } + + @Test + void updateLastAccessTimeThenUpdated() { + Authentication authentication = TestAuthentication.authenticatedUser(); + ReactiveSessionInformation sessionInformation = new ReactiveSessionInformation(authentication.getPrincipal(), + "1234", this.now); + this.sessionRegistry.saveSessionInformation(sessionInformation).block(); + ReactiveSessionInformation saved = this.sessionRegistry.getSessionInformation("1234").block(); + assertThat(saved.getLastAccessTime()).isNotNull(); + Instant lastAccessTimeBefore = saved.getLastAccessTime(); + this.sessionRegistry.updateLastAccessTime("1234").block(); + saved = this.sessionRegistry.getSessionInformation("1234").block(); + assertThat(saved.getLastAccessTime()).isNotNull(); + assertThat(saved.getLastAccessTime()).isAfter(lastAccessTimeBefore); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/session/InvalidateLeastUsedServerMaximumSessionsExceededHandlerTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/session/InvalidateLeastUsedServerMaximumSessionsExceededHandlerTests.java new file mode 100644 index 0000000000..60b6107418 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authentication/session/InvalidateLeastUsedServerMaximumSessionsExceededHandlerTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication.session; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.web.server.authentication.InvalidateLeastUsedServerMaximumSessionsExceededHandler; +import org.springframework.security.web.server.authentication.MaximumSessionsContext; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Tests for {@link InvalidateLeastUsedServerMaximumSessionsExceededHandler} + * + * @author Marcus da Coregio + */ +class InvalidateLeastUsedServerMaximumSessionsExceededHandlerTests { + + InvalidateLeastUsedServerMaximumSessionsExceededHandler handler = new InvalidateLeastUsedServerMaximumSessionsExceededHandler(); + + @Test + void handleWhenInvokedThenInvalidatesLeastRecentlyUsedSessions() { + ReactiveSessionInformation session1 = mock(ReactiveSessionInformation.class); + ReactiveSessionInformation session2 = mock(ReactiveSessionInformation.class); + given(session1.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760010L)); + given(session2.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760000L)); + given(session2.invalidate()).willReturn(Mono.empty()); + MaximumSessionsContext context = new MaximumSessionsContext(mock(Authentication.class), + List.of(session1, session2), 2); + + this.handler.handle(context).block(); + + verify(session2).invalidate(); + verify(session1).getLastAccessTime(); // used by comparator to sort the sessions + verify(session2).getLastAccessTime(); // used by comparator to sort the sessions + verifyNoMoreInteractions(session2); + verifyNoMoreInteractions(session1); + } + + @Test + void handleWhenMoreThanOneSessionToInvalidateThenInvalidatesAllOfThem() { + ReactiveSessionInformation session1 = mock(ReactiveSessionInformation.class); + ReactiveSessionInformation session2 = mock(ReactiveSessionInformation.class); + ReactiveSessionInformation session3 = mock(ReactiveSessionInformation.class); + given(session1.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760010L)); + given(session2.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760020L)); + given(session3.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760030L)); + given(session1.invalidate()).willReturn(Mono.empty()); + given(session2.invalidate()).willReturn(Mono.empty()); + MaximumSessionsContext context = new MaximumSessionsContext(mock(Authentication.class), + List.of(session1, session2, session3), 2); + + this.handler.handle(context).block(); + + // @formatter:off + verify(session1).invalidate(); + verify(session2).invalidate(); + verify(session1, atLeastOnce()).getLastAccessTime(); // used by comparator to sort the sessions + verify(session2, atLeastOnce()).getLastAccessTime(); // used by comparator to sort the sessions + verify(session3, atLeastOnce()).getLastAccessTime(); // used by comparator to sort the sessions + verifyNoMoreInteractions(session1); + verifyNoMoreInteractions(session2); + verifyNoMoreInteractions(session3); + // @formatter:on + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/session/PreventLoginServerMaximumSessionsExceededHandlerTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/session/PreventLoginServerMaximumSessionsExceededHandlerTests.java new file mode 100644 index 0000000000..819489ee43 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authentication/session/PreventLoginServerMaximumSessionsExceededHandlerTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication.session; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.web.authentication.session.SessionAuthenticationException; +import org.springframework.security.web.server.authentication.MaximumSessionsContext; +import org.springframework.security.web.server.authentication.PreventLoginServerMaximumSessionsExceededHandler; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link PreventLoginServerMaximumSessionsExceededHandler}. + * + * @author Marcus da Coregio + */ +class PreventLoginServerMaximumSessionsExceededHandlerTests { + + @Test + void handleWhenInvokedThenThrowsSessionAuthenticationException() { + PreventLoginServerMaximumSessionsExceededHandler handler = new PreventLoginServerMaximumSessionsExceededHandler(); + MaximumSessionsContext context = new MaximumSessionsContext(TestAuthentication.authenticatedUser(), + Collections.emptyList(), 1); + assertThatExceptionOfType(SessionAuthenticationException.class) + .isThrownBy(() -> handler.handle(context).block()) + .withMessage("Maximum sessions of 1 for authentication 'user' exceeded"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/session/RegisterSessionServerAuthenticationSuccessHandlerTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/session/RegisterSessionServerAuthenticationSuccessHandlerTests.java new file mode 100644 index 0000000000..1dbe3a6119 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authentication/session/RegisterSessionServerAuthenticationSuccessHandlerTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-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 org.springframework.security.web.server.authentication.session; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; + +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.mock.web.server.MockWebSession; +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.RegisterSessionServerAuthenticationSuccessHandler; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.WebSession; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class RegisterSessionServerAuthenticationSuccessHandlerTests { + + @InjectMocks + RegisterSessionServerAuthenticationSuccessHandler strategy; + + @Mock + ReactiveSessionRegistry sessionRegistry; + + @Mock + WebFilterChain filterChain; + + WebSession session = new MockWebSession(); + + ServerWebExchange serverWebExchange = MockServerWebExchange.builder(MockServerHttpRequest.get("")) + .session(this.session) + .build(); + + @Test + void constructorWhenSessionRegistryNullThenException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new RegisterSessionServerAuthenticationSuccessHandler(null)) + .withMessage("sessionRegistry cannot be null"); + } + + @Test + void onAuthenticationWhenSessionExistsThenSaveSessionInformation() { + given(this.sessionRegistry.saveSessionInformation(any())).willReturn(Mono.empty()); + WebFilterExchange webFilterExchange = new WebFilterExchange(this.serverWebExchange, this.filterChain); + Authentication authentication = TestAuthentication.authenticatedUser(); + this.strategy.onAuthenticationSuccess(webFilterExchange, authentication).block(); + ArgumentCaptor captor = ArgumentCaptor.forClass(ReactiveSessionInformation.class); + verify(this.sessionRegistry).saveSessionInformation(captor.capture()); + assertThat(captor.getValue().getSessionId()).isEqualTo(this.session.getId()); + assertThat(captor.getValue().getLastAccessTime()).isEqualTo(this.session.getLastAccessTime()); + assertThat(captor.getValue().getPrincipal()).isEqualTo(authentication.getPrincipal()); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistryTests.java b/web/src/test/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistryTests.java new file mode 100644 index 0000000000..e0430bdfcf --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistryTests.java @@ -0,0 +1,138 @@ +/* + * Copyright 2002-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 org.springframework.security.web.session; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.security.core.session.ReactiveSessionInformation; +import org.springframework.security.core.session.ReactiveSessionRegistry; +import org.springframework.web.server.WebSession; +import org.springframework.web.server.session.WebSessionStore; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link WebSessionStoreReactiveSessionRegistry} + * + * @author Marcus da Coregio + */ +class WebSessionStoreReactiveSessionRegistryTests { + + WebSessionStore webSessionStore = mock(); + + WebSessionStoreReactiveSessionRegistry registry = new WebSessionStoreReactiveSessionRegistry(this.webSessionStore); + + @Test + void constructorWhenWebSessionStoreNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> new WebSessionStoreReactiveSessionRegistry(null)) + .withMessage("webSessionStore cannot be null"); + } + + @Test + void getSessionInformationWhenSavedThenReturnsWebSessionInformation() { + ReactiveSessionInformation session = createSession(); + this.registry.saveSessionInformation(session).block(); + ReactiveSessionInformation saved = this.registry.getSessionInformation(session.getSessionId()).block(); + assertThat(saved).isInstanceOf(WebSessionStoreReactiveSessionRegistry.WebSessionInformation.class); + assertThat(saved.getPrincipal()).isEqualTo(session.getPrincipal()); + assertThat(saved.getSessionId()).isEqualTo(session.getSessionId()); + assertThat(saved.getLastAccessTime()).isEqualTo(session.getLastAccessTime()); + } + + @Test + void invalidateWhenReturnedFromGetSessionInformationThenWebSessionInvalidatedAndRemovedFromRegistry() { + ReactiveSessionInformation session = createSession(); + WebSession webSession = mock(); + given(webSession.invalidate()).willReturn(Mono.empty()); + given(this.webSessionStore.retrieveSession(session.getSessionId())).willReturn(Mono.just(webSession)); + + this.registry.saveSessionInformation(session).block(); + ReactiveSessionInformation saved = this.registry.getSessionInformation(session.getSessionId()).block(); + saved.invalidate().block(); + verify(webSession).invalidate(); + assertThat(this.registry.getSessionInformation(saved.getSessionId()).block()).isNull(); + } + + @Test + void invalidateWhenReturnedFromRemoveSessionInformationThenWebSessionInvalidatedAndRemovedFromRegistry() { + ReactiveSessionInformation session = createSession(); + WebSession webSession = mock(); + given(webSession.invalidate()).willReturn(Mono.empty()); + given(this.webSessionStore.retrieveSession(session.getSessionId())).willReturn(Mono.just(webSession)); + + this.registry.saveSessionInformation(session).block(); + ReactiveSessionInformation saved = this.registry.removeSessionInformation(session.getSessionId()).block(); + saved.invalidate().block(); + verify(webSession).invalidate(); + assertThat(this.registry.getSessionInformation(saved.getSessionId()).block()).isNull(); + } + + @Test + void invalidateWhenReturnedFromGetAllSessionsThenWebSessionInvalidatedAndRemovedFromRegistry() { + ReactiveSessionInformation session = createSession(); + WebSession webSession = mock(); + given(webSession.invalidate()).willReturn(Mono.empty()); + given(this.webSessionStore.retrieveSession(session.getSessionId())).willReturn(Mono.just(webSession)); + + this.registry.saveSessionInformation(session).block(); + List saved = this.registry.getAllSessions(session.getPrincipal(), false) + .collectList() + .block(); + saved.forEach((info) -> info.invalidate().block()); + verify(webSession).invalidate(); + assertThat(this.registry.getAllSessions(session.getPrincipal(), false).collectList().block()).isEmpty(); + } + + @Test + void setSessionRegistryThenUses() { + ReactiveSessionRegistry sessionRegistry = mock(); + given(sessionRegistry.saveSessionInformation(any())).willReturn(Mono.empty()); + given(sessionRegistry.removeSessionInformation(any())).willReturn(Mono.empty()); + given(sessionRegistry.updateLastAccessTime(any())).willReturn(Mono.empty()); + given(sessionRegistry.getSessionInformation(any())).willReturn(Mono.empty()); + given(sessionRegistry.getAllSessions(any(), anyBoolean())).willReturn(Flux.empty()); + this.registry.setSessionRegistry(sessionRegistry); + ReactiveSessionInformation session = createSession(); + this.registry.saveSessionInformation(session).block(); + verify(sessionRegistry).saveSessionInformation(any()); + this.registry.removeSessionInformation(session.getSessionId()).block(); + verify(sessionRegistry).removeSessionInformation(any()); + this.registry.updateLastAccessTime(session.getSessionId()).block(); + verify(sessionRegistry).updateLastAccessTime(any()); + this.registry.getSessionInformation(session.getSessionId()).block(); + verify(sessionRegistry).getSessionInformation(any()); + this.registry.getAllSessions(session.getPrincipal(), false).blockFirst(); + verify(sessionRegistry).getAllSessions(any(), eq(false)); + } + + private static ReactiveSessionInformation createSession() { + return new ReactiveSessionInformation("principal", "sessionId", Instant.now()); + } + +}