Add Max Sessions on WebFlux

Closes gh-6192
This commit is contained in:
Marcus Da Coregio 2023-07-25 15:31:30 -03:00 committed by Marcus Hert Da Coregio
parent 64feedf67e
commit 57ab15127a
30 changed files with 3342 additions and 23 deletions

View File

@ -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() {

View File

@ -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]
*/

View File

@ -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!!)
}
}
}
}

View File

@ -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) }
}
}
}

View File

@ -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

View File

@ -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";
}
}
}

View File

@ -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"
}
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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]

View File

@ -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].

View File

@ -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

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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)

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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"));
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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));
}
}
}

View File

@ -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

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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
}
}

View File

@ -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");
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}