parent
64feedf67e
commit
57ab15127a
|
@ -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">
|
||||
* @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() {
|
||||
|
|
|
@ -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]
|
||||
*/
|
||||
|
|
|
@ -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!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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].
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue