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