Browse Source

Add Max Sessions on WebFlux

Closes gh-6192
Marcus Da Coregio 2 years ago
parent
commit
57ab15127a
30 changed files with 3342 additions and 23 deletions
  1. 399 12
      config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java
  2. 30 0
      config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt
  3. 48 0
      config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionConcurrencyDsl.kt
  4. 64 0
      config/src/main/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDsl.kt
  5. 8 2
      config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java
  6. 624 0
      config/src/test/java/org/springframework/security/config/web/server/SessionManagementSpecTests.java
  7. 283 0
      config/src/test/kotlin/org/springframework/security/config/web/server/ServerSessionManagementDslTests.kt
  8. 95 0
      core/src/main/java/org/springframework/security/core/session/InMemoryReactiveSessionRegistry.java
  9. 83 0
      core/src/main/java/org/springframework/security/core/session/ReactiveSessionInformation.java
  10. 67 0
      core/src/main/java/org/springframework/security/core/session/ReactiveSessionRegistry.java
  11. 2 0
      docs/modules/ROOT/nav.adoc
  12. 465 0
      docs/modules/ROOT/pages/reactive/authentication/concurrent-sessions-control.adoc
  13. 4 0
      docs/modules/ROOT/pages/whats-new.adoc
  14. 7 4
      web/src/main/java/org/springframework/security/web/authentication/session/SessionAuthenticationException.java
  15. 111 0
      web/src/main/java/org/springframework/security/web/server/authentication/ConcurrentSessionControlServerAuthenticationSuccessHandler.java
  16. 11 1
      web/src/main/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandler.java
  17. 61 0
      web/src/main/java/org/springframework/security/web/server/authentication/InvalidateLeastUsedServerMaximumSessionsExceededHandler.java
  18. 51 0
      web/src/main/java/org/springframework/security/web/server/authentication/MaximumSessionsContext.java
  19. 39 0
      web/src/main/java/org/springframework/security/web/server/authentication/PreventLoginServerMaximumSessionsExceededHandler.java
  20. 52 0
      web/src/main/java/org/springframework/security/web/server/authentication/RegisterSessionServerAuthenticationSuccessHandler.java
  21. 38 0
      web/src/main/java/org/springframework/security/web/server/authentication/ServerMaximumSessionsExceededHandler.java
  22. 50 0
      web/src/main/java/org/springframework/security/web/server/authentication/SessionLimit.java
  23. 100 0
      web/src/main/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistry.java
  24. 12 4
      web/src/test/java/org/springframework/security/web/server/authentication/DelegatingServerAuthenticationSuccessHandlerTests.java
  25. 171 0
      web/src/test/java/org/springframework/security/web/server/authentication/session/ConcurrentSessionControlServerAuthenticationSuccessHandlerTests.java
  26. 107 0
      web/src/test/java/org/springframework/security/web/server/authentication/session/InMemoryReactiveSessionRegistryTests.java
  27. 91 0
      web/src/test/java/org/springframework/security/web/server/authentication/session/InvalidateLeastUsedServerMaximumSessionsExceededHandlerTests.java
  28. 47 0
      web/src/test/java/org/springframework/security/web/server/authentication/session/PreventLoginServerMaximumSessionsExceededHandlerTests.java
  29. 84 0
      web/src/test/java/org/springframework/security/web/server/authentication/session/RegisterSessionServerAuthenticationSuccessHandlerTests.java
  30. 138 0
      web/src/test/java/org/springframework/security/web/session/WebSessionStoreReactiveSessionRegistryTests.java

+ 399 - 12
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:
+	 * <pre class="code">
+	 *  &#064;Bean
+	 *  SecurityWebFilterChain filterChain(ServerHttpSecurity http, ReactiveSessionRegistry sessionRegistry) {
+	 *      http
+	 *          // ...
+	 *          .sessionManagement((sessionManagement) -> sessionManagement
+	 *              .concurrentSessions((concurrentSessions) -> concurrentSessions
+	 *                  .maxSessions(1)
+	 *                  .maxSessionsPreventsLogin(true)
+	 *                  .sessionRegistry(sessionRegistry)
+	 *              )
+	 *          );
+	 *      return http.build();
+	 *  }
+	 * </pre>
+	 * @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<SessionManagementSpec> 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<ConcurrentSessionsSpec> 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: <pre>
+			 *     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
+			 *                 })
+			 *             )
+			 *         )
+			 * </pre>
+			 * @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<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+				return chain.filter(new SessionRegistryWebExchange(exchange));
+			}
+
+			private final class SessionRegistryWebExchange extends ServerWebExchangeDecorator {
+
+				private final Mono<WebSession> 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<WebSession> 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<String, Object> getAttributes() {
+					return this.session.getAttributes();
+				}
+
+				@Override
+				public void start() {
+					this.session.start();
+				}
+
+				@Override
+				public boolean isStarted() {
+					return this.session.isStarted();
+				}
+
+				@Override
+				public Mono<Void> 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<Void> invalidate() {
+					String currentId = this.session.getId();
+					return SessionRegistryWebFilter.this.sessionRegistry.removeSessionInformation(currentId)
+						.flatMap((information) -> this.session.invalidate());
+				}
+
+				@Override
+				public Mono<Void> 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<ServerAuthenticationSuccessHandler> defaultSuccessHandlers = new ArrayList<>(
+				List.of(new WebFilterChainServerAuthenticationSuccessHandler()));
+
+		private List<ServerAuthenticationSuccessHandler> authenticationSuccessHandlers = new ArrayList<>();
+
 		private HttpBasicSpec() {
 			List<DelegateEntry> 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<List<ServerAuthenticationSuccessHandler>> 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<ServerAuthenticationSuccessHandler> 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<ServerAuthenticationSuccessHandler> 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<List<ServerAuthenticationSuccessHandler>> 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<ServerAuthenticationSuccessHandler> defaultSuccessHandlers = new ArrayList<>(
+				List.of(this.defaultAuthenticationSuccessHandler));
+
+		private List<ServerAuthenticationSuccessHandler> 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<List<ServerAuthenticationSuccessHandler>> 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() {

+ 30 - 0
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]
      */

+ 48 - 0
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!!)
+            }
+        }
+    }
+}

+ 64 - 0
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) }
+        }
+    }
+}

+ 8 - 2
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<ServerAuthenticationSuccessHandler> delegates = (List<ServerAuthenticationSuccessHandler>) ReflectionTestUtils
+			.getField(handler, "delegates");
+		assertThat(ReflectionTestUtils.getField(delegates.get(0), "requestCache")).isSameAs(requestCache);
 	}
 
 	@Test

+ 624 - 0
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<String, String> 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<String, String> 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<String, String> data = new LinkedMultiValueMap<>();
+		data.add("username", "user");
+		data.add("password", "password");
+		MultiValueMap<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> data) {
+		return login(data).expectCookie()
+			.exists("SESSION")
+			.returnResult(Void.class)
+			.getResponseCookies()
+			.getFirst("SESSION");
+	}
+
+	private WebTestClient.ResponseSpec login(MultiValueMap<String, String> 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";
+		}
+
+	}
+
+}

+ 283 - 0
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<String, String> = 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<String, String> = 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<String, String> = 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<String, String>): ResponseCookie? {
+        return login(data).expectCookie()
+            .exists("SESSION")
+            .returnResult(Void::class.java)
+            .responseCookies
+            .getFirst("SESSION")
+    }
+
+    private fun login(data: MultiValueMap<String, String>): 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"
+        }
+
+    }
+
+
+}

+ 95 - 0
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<Object, Set<String>> sessionIdsByPrincipal;
+
+	private final Map<String, ReactiveSessionInformation> sessionById;
+
+	public InMemoryReactiveSessionRegistry() {
+		this.sessionIdsByPrincipal = new ConcurrentHashMap<>();
+		this.sessionById = new ConcurrentHashMap<>();
+	}
+
+	public InMemoryReactiveSessionRegistry(ConcurrentMap<Object, Set<String>> sessionIdsByPrincipal,
+			Map<String, ReactiveSessionInformation> sessionById) {
+		this.sessionIdsByPrincipal = sessionIdsByPrincipal;
+		this.sessionById = sessionById;
+	}
+
+	@Override
+	public Flux<ReactiveSessionInformation> 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<Void> 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<ReactiveSessionInformation> getSessionInformation(String sessionId) {
+		return Mono.justOrEmpty(this.sessionById.get(sessionId));
+	}
+
+	@Override
+	public Mono<ReactiveSessionInformation> removeSessionInformation(String sessionId) {
+		return getSessionInformation(sessionId).doOnNext((sessionInformation) -> {
+			this.sessionById.remove(sessionId);
+			Set<String> sessionsUsedByPrincipal = this.sessionIdsByPrincipal.get(sessionInformation.getPrincipal());
+			if (sessionsUsedByPrincipal != null) {
+				sessionsUsedByPrincipal.remove(sessionId);
+				if (sessionsUsedByPrincipal.isEmpty()) {
+					this.sessionIdsByPrincipal.remove(sessionInformation.getPrincipal());
+				}
+			}
+		});
+	}
+
+	@Override
+	public Mono<ReactiveSessionInformation> updateLastAccessTime(String sessionId) {
+		ReactiveSessionInformation session = this.sessionById.get(sessionId);
+		if (session != null) {
+			return session.refreshLastRequest().thenReturn(session);
+		}
+		return Mono.empty();
+	}
+
+}

+ 83 - 0
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<Void> invalidate() {
+		return Mono.fromRunnable(() -> this.expired = true);
+	}
+
+	public Mono<Void> 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;
+	}
+
+}

+ 67 - 0
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<ReactiveSessionInformation> 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<Void> 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<ReactiveSessionInformation> 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<ReactiveSessionInformation> 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<ReactiveSessionInformation> updateLastAccessTime(String sessionId);
+
+}

+ 2 - 0
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]

+ 465 - 0
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,I want to limit the number of concurrent sessions a user can have>>
+* <<concurrent-sessions-control-custom-strategy,I want to customize the strategy used when the maximum number of sessions is exceeded>>
+* <<reactive-concurrent-sessions-control-specify-session-registry,I want to know how to specify a `ReactiveSessionRegistry`>>
+* <<concurrent-sessions-control-sample,I want to see a sample application that uses Concurrent Sessions Control>>
+* <<disabling-for-authentication-filters,I want to know how to disable it for some authentication filter>>
+
+[[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<Authentication, Mono<Integer>>`, 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,customize the strategy used when the maximum number of sessions is exceeded>>.
+
+[[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<Void> 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<List<ServerAuthenticationSuccessHandler>>)` 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].

+ 4 - 0
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

+ 7 - 4
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 <tt>SessionAuthenticationStrategy</tt> 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 {
 

+ 111 - 0
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<Void> onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) {
+		return this.sessionLimit.apply(authentication)
+			.flatMap((maxSessions) -> handleConcurrency(exchange, authentication, maxSessions));
+	}
+
+	private Mono<Void> 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<ReactiveSessionInformation> 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;
+	}
+
+}

+ 11 - 1
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<ServerAuthenticationSuccessHandler> delegates) {
+		Assert.notEmpty(delegates, "delegates cannot be null or empty");
+		this.delegates = delegates;
+	}
+
 	@Override
 	public Mono<Void> onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) {
 		return Flux.fromIterable(this.delegates)

+ 61 - 0
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<Void> handle(MaximumSessionsContext context) {
+		List<ReactiveSessionInformation> sessions = new ArrayList<>(context.getSessions());
+		sessions.sort(Comparator.comparing(ReactiveSessionInformation::getLastAccessTime));
+		int maximumSessionsExceededBy = sessions.size() - context.getMaximumSessionsAllowed() + 1;
+		List<ReactiveSessionInformation> 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();
+	}
+
+}

+ 51 - 0
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<ReactiveSessionInformation> sessions;
+
+	private final int maximumSessionsAllowed;
+
+	public MaximumSessionsContext(Authentication authentication, List<ReactiveSessionInformation> sessions,
+			int maximumSessionsAllowed) {
+		this.authentication = authentication;
+		this.sessions = sessions;
+		this.maximumSessionsAllowed = maximumSessionsAllowed;
+	}
+
+	public Authentication getAuthentication() {
+		return this.authentication;
+	}
+
+	public List<ReactiveSessionInformation> getSessions() {
+		return this.sessions;
+	}
+
+	public int getMaximumSessionsAllowed() {
+		return this.maximumSessionsAllowed;
+	}
+
+}

+ 39 - 0
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<Void> handle(MaximumSessionsContext context) {
+		return Mono
+			.error(new SessionAuthenticationException("Maximum sessions of " + context.getMaximumSessionsAllowed()
+					+ " for authentication '" + context.getAuthentication().getName() + "' exceeded"));
+	}
+
+}

+ 52 - 0
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<Void> onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) {
+		return exchange.getExchange()
+			.getSession()
+			.map((session) -> new ReactiveSessionInformation(authentication.getPrincipal(), session.getId(),
+					session.getLastAccessTime()))
+			.flatMap(this.sessionRegistry::saveSessionInformation);
+	}
+
+}

+ 38 - 0
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<Void> handle(MaximumSessionsContext context);
+
+}

+ 50 - 0
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<Authentication, Mono<Integer>> {
+
+	/**
+	 * 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);
+	}
+
+}

+ 100 - 0
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<ReactiveSessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
+		return this.sessionRegistry.getAllSessions(principal, includeExpiredSessions).map(WebSessionInformation::new);
+	}
+
+	@Override
+	public Mono<Void> saveSessionInformation(ReactiveSessionInformation information) {
+		return this.sessionRegistry.saveSessionInformation(new WebSessionInformation(information));
+	}
+
+	@Override
+	public Mono<ReactiveSessionInformation> getSessionInformation(String sessionId) {
+		return this.sessionRegistry.getSessionInformation(sessionId).map(WebSessionInformation::new);
+	}
+
+	@Override
+	public Mono<ReactiveSessionInformation> removeSessionInformation(String sessionId) {
+		return this.sessionRegistry.removeSessionInformation(sessionId).map(WebSessionInformation::new);
+	}
+
+	@Override
+	public Mono<ReactiveSessionInformation> 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<Void> invalidate() {
+			return WebSessionStoreReactiveSessionRegistry.this.webSessionStore.retrieveSession(getSessionId())
+				.flatMap(WebSession::invalidate)
+				.then(Mono
+					.defer(() -> WebSessionStoreReactiveSessionRegistry.this.removeSessionInformation(getSessionId())))
+				.then(Mono.defer(super::invalidate));
+		}
+
+	}
+
+}

+ 12 - 4
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<ServerAuthenticationSuccessHandler>) null));
+	}
+
+	@Test
+	public void constructorWhenEmptyListThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new DelegatingServerAuthenticationSuccessHandler(Collections.emptyList()));
 	}
 
 	@Test

+ 171 - 0
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<MaximumSessionsContext> 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<ReactiveSessionInformation> 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<ReactiveSessionInformation> 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<ReactiveSessionInformation> userSessions = Arrays.asList(createSessionInformation("100"));
+		List<ReactiveSessionInformation> 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());
+	}
+
+}

+ 107 - 0
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<ReactiveSessionInformation> 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<ReactiveSessionInformation> 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<ReactiveSessionInformation> 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);
+	}
+
+}

+ 91 - 0
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
+	}
+
+}

+ 47 - 0
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");
+	}
+
+}

+ 84 - 0
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<ReactiveSessionInformation> 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());
+	}
+
+}

+ 138 - 0
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<ReactiveSessionInformation> 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());
+	}
+
+}