Add Max Sessions on WebFlux

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2016 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,12 +19,15 @@ package org.springframework.security.web.authentication.session;
import org.springframework.security.core.AuthenticationException;
/**
* Thrown by an <tt>SessionAuthenticationStrategy</tt> to indicate that an authentication
* object is not valid for the current session, typically because the same user has
* exceeded the number of sessions they are allowed to have concurrently.
* Thrown by an {@link SessionAuthenticationStrategy} or
* {@link ServerSessionAuthenticationStrategy} to indicate that an authentication object
* is not valid for the current session, typically because the same user has exceeded the
* number of sessions they are allowed to have concurrently.
*
* @author Luke Taylor
* @since 3.0
* @see SessionAuthenticationStrategy
* @see ServerSessionAuthenticationStrategy
*/
public class SessionAuthenticationException extends AuthenticationException {

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication;
import java.util.List;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuples;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.ReactiveSessionInformation;
import org.springframework.security.core.session.ReactiveSessionRegistry;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.util.Assert;
import org.springframework.web.server.WebSession;
/**
* Controls the number of sessions a user can have concurrently authenticated in an
* application. It also allows for customizing behaviour when an authentication attempt is
* made while the user already has the maximum number of sessions open. By default, it
* allows a maximum of 1 session per user, if the maximum is exceeded, the user's least
* recently used session(s) will be expired.
*
* @author Marcus da Coregio
* @since 6.3
* @see ServerMaximumSessionsExceededHandler
* @see RegisterSessionServerAuthenticationSuccessHandler
*/
public final class ConcurrentSessionControlServerAuthenticationSuccessHandler
implements ServerAuthenticationSuccessHandler {
private final ReactiveSessionRegistry sessionRegistry;
private SessionLimit sessionLimit = SessionLimit.of(1);
private ServerMaximumSessionsExceededHandler maximumSessionsExceededHandler = new InvalidateLeastUsedServerMaximumSessionsExceededHandler();
public ConcurrentSessionControlServerAuthenticationSuccessHandler(ReactiveSessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) {
return this.sessionLimit.apply(authentication)
.flatMap((maxSessions) -> handleConcurrency(exchange, authentication, maxSessions));
}
private Mono<Void> handleConcurrency(WebFilterExchange exchange, Authentication authentication,
Integer maximumSessions) {
return this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false)
.collectList()
.flatMap((registeredSessions) -> exchange.getExchange()
.getSession()
.map((currentSession) -> Tuples.of(currentSession, registeredSessions)))
.flatMap((sessionTuple) -> {
WebSession currentSession = sessionTuple.getT1();
List<ReactiveSessionInformation> registeredSessions = sessionTuple.getT2();
int registeredSessionsCount = registeredSessions.size();
if (registeredSessionsCount < maximumSessions) {
return Mono.empty();
}
if (registeredSessionsCount == maximumSessions) {
for (ReactiveSessionInformation registeredSession : registeredSessions) {
if (registeredSession.getSessionId().equals(currentSession.getId())) {
return Mono.empty();
}
}
}
return this.maximumSessionsExceededHandler
.handle(new MaximumSessionsContext(authentication, registeredSessions, maximumSessions));
});
}
/**
* Sets the strategy used to resolve the maximum number of sessions that are allowed
* for a specific {@link Authentication}. By default, it returns {@code 1} for any
* authentication.
* @param sessionLimit the {@link SessionLimit} to use
*/
public void setSessionLimit(SessionLimit sessionLimit) {
Assert.notNull(sessionLimit, "sessionLimit cannot be null");
this.sessionLimit = sessionLimit;
}
/**
* Sets the {@link ServerMaximumSessionsExceededHandler} to use. The default is
* {@link InvalidateLeastUsedServerMaximumSessionsExceededHandler}.
* @param maximumSessionsExceededHandler the
* {@link ServerMaximumSessionsExceededHandler} to use
*/
public void setMaximumSessionsExceededHandler(ServerMaximumSessionsExceededHandler maximumSessionsExceededHandler) {
Assert.notNull(maximumSessionsExceededHandler, "maximumSessionsExceededHandler cannot be null");
this.maximumSessionsExceededHandler = maximumSessionsExceededHandler;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -42,6 +42,16 @@ public class DelegatingServerAuthenticationSuccessHandler implements ServerAuthe
this.delegates = Arrays.asList(delegates);
}
/**
* Creates a new instance with the provided list of delegates
* @param delegates the {@link List} of {@link ServerAuthenticationSuccessHandler}
* @since 6.3
*/
public DelegatingServerAuthenticationSuccessHandler(List<ServerAuthenticationSuccessHandler> delegates) {
Assert.notEmpty(delegates, "delegates cannot be null or empty");
this.delegates = delegates;
}
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) {
return Flux.fromIterable(this.delegates)

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.log.LogMessage;
import org.springframework.security.core.session.ReactiveSessionInformation;
/**
* Implementation of {@link ServerMaximumSessionsExceededHandler} that invalidates the
* least recently used session(s). It only invalidates the amount of sessions that exceed
* the maximum allowed. For example, if the maximum was exceeded by 1, only the least
* recently used session will be invalidated.
*
* @author Marcus da Coregio
* @since 6.3
*/
public final class InvalidateLeastUsedServerMaximumSessionsExceededHandler
implements ServerMaximumSessionsExceededHandler {
private final Log logger = LogFactory.getLog(getClass());
@Override
public Mono<Void> handle(MaximumSessionsContext context) {
List<ReactiveSessionInformation> sessions = new ArrayList<>(context.getSessions());
sessions.sort(Comparator.comparing(ReactiveSessionInformation::getLastAccessTime));
int maximumSessionsExceededBy = sessions.size() - context.getMaximumSessionsAllowed() + 1;
List<ReactiveSessionInformation> leastRecentlyUsedSessionsToInvalidate = sessions.subList(0,
maximumSessionsExceededBy);
return Flux.fromIterable(leastRecentlyUsedSessionsToInvalidate)
.doOnComplete(() -> this.logger
.debug(LogMessage.format("Invalidated %d least recently used sessions for authentication %s",
leastRecentlyUsedSessionsToInvalidate.size(), context.getAuthentication().getName())))
.flatMap(ReactiveSessionInformation::invalidate)
.then();
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication;
import java.util.List;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.ReactiveSessionInformation;
public final class MaximumSessionsContext {
private final Authentication authentication;
private final List<ReactiveSessionInformation> sessions;
private final int maximumSessionsAllowed;
public MaximumSessionsContext(Authentication authentication, List<ReactiveSessionInformation> sessions,
int maximumSessionsAllowed) {
this.authentication = authentication;
this.sessions = sessions;
this.maximumSessionsAllowed = maximumSessionsAllowed;
}
public Authentication getAuthentication() {
return this.authentication;
}
public List<ReactiveSessionInformation> getSessions() {
return this.sessions;
}
public int getMaximumSessionsAllowed() {
return this.maximumSessionsAllowed;
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication;
import reactor.core.publisher.Mono;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
/**
* Returns a {@link Mono} that terminates with {@link SessionAuthenticationException} when
* the maximum number of sessions for a user has been reached.
*
* @author Marcus da Coregio
* @since 6.3
*/
public final class PreventLoginServerMaximumSessionsExceededHandler implements ServerMaximumSessionsExceededHandler {
@Override
public Mono<Void> handle(MaximumSessionsContext context) {
return Mono
.error(new SessionAuthenticationException("Maximum sessions of " + context.getMaximumSessionsAllowed()
+ " for authentication '" + context.getAuthentication().getName() + "' exceeded"));
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication;
import reactor.core.publisher.Mono;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.ReactiveSessionInformation;
import org.springframework.security.core.session.ReactiveSessionRegistry;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.util.Assert;
/**
* An implementation of {@link ServerAuthenticationSuccessHandler} that will register a
* {@link ReactiveSessionInformation} with the provided {@link ReactiveSessionRegistry}.
*
* @author Marcus da Coregio
* @since 6.3
*/
public final class RegisterSessionServerAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler {
private final ReactiveSessionRegistry sessionRegistry;
public RegisterSessionServerAuthenticationSuccessHandler(ReactiveSessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange exchange, Authentication authentication) {
return exchange.getExchange()
.getSession()
.map((session) -> new ReactiveSessionInformation(authentication.getPrincipal(), session.getId(),
session.getLastAccessTime()))
.flatMap(this.sessionRegistry::saveSessionInformation);
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication;
import reactor.core.publisher.Mono;
/**
* Strategy for handling the scenario when the maximum number of sessions for a user has
* been reached.
*
* @author Marcus da Coregio
* @since 6.3
*/
public interface ServerMaximumSessionsExceededHandler {
/**
* Handles the scenario when the maximum number of sessions for a user has been
* reached.
* @param context the context with information about the sessions and the user
* @return an empty {@link Mono} that completes when the handling is done
*/
Mono<Void> handle(MaximumSessionsContext context);
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication;
import java.util.function.Function;
import reactor.core.publisher.Mono;
import org.springframework.security.core.Authentication;
/**
* Represents the maximum number of sessions allowed. Use {@link #UNLIMITED} to indicate
* that there is no limit.
*
* @author Marcus da Coregio
* @since 6.3
* @see ConcurrentSessionControlServerAuthenticationSuccessHandler
*/
public interface SessionLimit extends Function<Authentication, Mono<Integer>> {
/**
* Represents unlimited sessions. This is just a shortcut to return
* {@link Mono#empty()} for any user.
*/
SessionLimit UNLIMITED = (authentication) -> Mono.empty();
/**
* Creates a {@link SessionLimit} that always returns the given value for any user
* @param maxSessions the maximum number of sessions allowed
* @return a {@link SessionLimit} instance that returns the given value.
*/
static SessionLimit of(int maxSessions) {
return (authentication) -> Mono.just(maxSessions);
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.session;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.security.core.session.InMemoryReactiveSessionRegistry;
import org.springframework.security.core.session.ReactiveSessionInformation;
import org.springframework.security.core.session.ReactiveSessionRegistry;
import org.springframework.util.Assert;
import org.springframework.web.server.WebSession;
import org.springframework.web.server.session.WebSessionStore;
/**
* A {@link ReactiveSessionRegistry} implementation that uses a {@link WebSessionStore} to
* invalidate a {@link WebSession} when the {@link ReactiveSessionInformation} is
* invalidated.
*
* @author Marcus da Coregio
* @since 6.3
*/
public final class WebSessionStoreReactiveSessionRegistry implements ReactiveSessionRegistry {
private final WebSessionStore webSessionStore;
private ReactiveSessionRegistry sessionRegistry = new InMemoryReactiveSessionRegistry();
public WebSessionStoreReactiveSessionRegistry(WebSessionStore webSessionStore) {
Assert.notNull(webSessionStore, "webSessionStore cannot be null");
this.webSessionStore = webSessionStore;
}
@Override
public Flux<ReactiveSessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
return this.sessionRegistry.getAllSessions(principal, includeExpiredSessions).map(WebSessionInformation::new);
}
@Override
public Mono<Void> saveSessionInformation(ReactiveSessionInformation information) {
return this.sessionRegistry.saveSessionInformation(new WebSessionInformation(information));
}
@Override
public Mono<ReactiveSessionInformation> getSessionInformation(String sessionId) {
return this.sessionRegistry.getSessionInformation(sessionId).map(WebSessionInformation::new);
}
@Override
public Mono<ReactiveSessionInformation> removeSessionInformation(String sessionId) {
return this.sessionRegistry.removeSessionInformation(sessionId).map(WebSessionInformation::new);
}
@Override
public Mono<ReactiveSessionInformation> updateLastAccessTime(String sessionId) {
return this.sessionRegistry.updateLastAccessTime(sessionId).map(WebSessionInformation::new);
}
/**
* Sets the {@link ReactiveSessionRegistry} to use.
* @param sessionRegistry the {@link ReactiveSessionRegistry} to use. Cannot be null.
*/
public void setSessionRegistry(ReactiveSessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
final class WebSessionInformation extends ReactiveSessionInformation {
WebSessionInformation(ReactiveSessionInformation sessionInformation) {
super(sessionInformation.getPrincipal(), sessionInformation.getSessionId(),
sessionInformation.getLastAccessTime());
}
@Override
public Mono<Void> invalidate() {
return WebSessionStoreReactiveSessionRegistry.this.webSessionStore.retrieveSession(getSessionId())
.flatMap(WebSession::invalidate)
.then(Mono
.defer(() -> WebSessionStoreReactiveSessionRegistry.this.removeSessionInformation(getSessionId())))
.then(Mono.defer(super::invalidate));
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,6 +17,8 @@
package org.springframework.security.web.server.authentication;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -74,9 +76,15 @@ public class DelegatingServerAuthenticationSuccessHandlerTests {
}
@Test
public void constructorWhenEmptyThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(
() -> new DelegatingServerAuthenticationSuccessHandler(new ServerAuthenticationSuccessHandler[0]));
public void constructorWhenNullListThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> new DelegatingServerAuthenticationSuccessHandler(
(List<ServerAuthenticationSuccessHandler>) null));
}
@Test
public void constructorWhenEmptyListThenIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new DelegatingServerAuthenticationSuccessHandler(Collections.emptyList()));
}
@Test

View File

@@ -0,0 +1,171 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.session;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockWebSession;
import org.springframework.security.authentication.TestAuthentication;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.ReactiveSessionInformation;
import org.springframework.security.core.session.ReactiveSessionRegistry;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.ConcurrentSessionControlServerAuthenticationSuccessHandler;
import org.springframework.security.web.server.authentication.MaximumSessionsContext;
import org.springframework.security.web.server.authentication.ServerMaximumSessionsExceededHandler;
import org.springframework.security.web.server.authentication.SessionLimit;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Tests for {@link ConcurrentSessionControlServerAuthenticationSuccessHandler}.
*
* @author Marcus da Coregio
*/
class ConcurrentSessionControlServerAuthenticationSuccessHandlerTests {
private ConcurrentSessionControlServerAuthenticationSuccessHandler strategy;
ReactiveSessionRegistry sessionRegistry = mock();
ServerWebExchange exchange = mock();
WebFilterChain chain = mock();
ServerMaximumSessionsExceededHandler handler = mock();
ArgumentCaptor<MaximumSessionsContext> contextCaptor = ArgumentCaptor.forClass(MaximumSessionsContext.class);
@BeforeEach
void setup() {
given(this.exchange.getResponse()).willReturn(new MockServerHttpResponse());
given(this.exchange.getRequest()).willReturn(MockServerHttpRequest.get("/").build());
given(this.exchange.getSession()).willReturn(Mono.just(new MockWebSession()));
given(this.handler.handle(any())).willReturn(Mono.empty());
this.strategy = new ConcurrentSessionControlServerAuthenticationSuccessHandler(this.sessionRegistry);
this.strategy.setMaximumSessionsExceededHandler(this.handler);
}
@Test
void constructorWhenNullRegistryThenException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ConcurrentSessionControlServerAuthenticationSuccessHandler(null))
.withMessage("sessionRegistry cannot be null");
}
@Test
void setMaximumSessionsForAuthenticationWhenNullThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.strategy.setSessionLimit(null))
.withMessage("sessionLimit cannot be null");
}
@Test
void setMaximumSessionsExceededHandlerWhenNullThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.strategy.setMaximumSessionsExceededHandler(null))
.withMessage("maximumSessionsExceededHandler cannot be null");
}
@Test
void onAuthenticationWhenSessionLimitIsUnlimitedThenDoNothing() {
ServerMaximumSessionsExceededHandler handler = mock(ServerMaximumSessionsExceededHandler.class);
this.strategy.setSessionLimit(SessionLimit.UNLIMITED);
this.strategy.setMaximumSessionsExceededHandler(handler);
this.strategy.onAuthenticationSuccess(null, TestAuthentication.authenticatedUser()).block();
verifyNoInteractions(handler, this.sessionRegistry);
}
@Test
void onAuthenticationWhenMaximumSessionsIsOneAndExceededThenHandlerIsCalled() {
Authentication authentication = TestAuthentication.authenticatedUser();
List<ReactiveSessionInformation> sessions = Arrays.asList(createSessionInformation("100"),
createSessionInformation("101"));
given(this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false))
.willReturn(Flux.fromIterable(sessions));
this.strategy.onAuthenticationSuccess(new WebFilterExchange(this.exchange, this.chain), authentication).block();
verify(this.handler).handle(this.contextCaptor.capture());
assertThat(this.contextCaptor.getValue().getMaximumSessionsAllowed()).isEqualTo(1);
assertThat(this.contextCaptor.getValue().getSessions()).isEqualTo(sessions);
assertThat(this.contextCaptor.getValue().getAuthentication()).isEqualTo(authentication);
}
@Test
void onAuthenticationWhenMaximumSessionsIsGreaterThanOneAndExceededThenHandlerIsCalled() {
this.strategy.setSessionLimit(SessionLimit.of(5));
Authentication authentication = TestAuthentication.authenticatedUser();
List<ReactiveSessionInformation> sessions = Arrays.asList(createSessionInformation("100"),
createSessionInformation("101"), createSessionInformation("102"), createSessionInformation("103"),
createSessionInformation("104"));
given(this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false))
.willReturn(Flux.fromIterable(sessions));
this.strategy.onAuthenticationSuccess(new WebFilterExchange(this.exchange, this.chain), authentication).block();
verify(this.handler).handle(this.contextCaptor.capture());
assertThat(this.contextCaptor.getValue().getMaximumSessionsAllowed()).isEqualTo(5);
assertThat(this.contextCaptor.getValue().getSessions()).isEqualTo(sessions);
assertThat(this.contextCaptor.getValue().getAuthentication()).isEqualTo(authentication);
}
@Test
void onAuthenticationWhenMaximumSessionsForUsersAreDifferentThenHandlerIsCalledWhereNeeded() {
Authentication user = TestAuthentication.authenticatedUser();
Authentication admin = TestAuthentication.authenticatedAdmin();
this.strategy.setSessionLimit((authentication) -> {
if (authentication.equals(user)) {
return Mono.just(1);
}
return Mono.just(3);
});
List<ReactiveSessionInformation> userSessions = Arrays.asList(createSessionInformation("100"));
List<ReactiveSessionInformation> adminSessions = Arrays.asList(createSessionInformation("200"),
createSessionInformation("201"));
given(this.sessionRegistry.getAllSessions(user.getPrincipal(), false))
.willReturn(Flux.fromIterable(userSessions));
given(this.sessionRegistry.getAllSessions(admin.getPrincipal(), false))
.willReturn(Flux.fromIterable(adminSessions));
this.strategy.onAuthenticationSuccess(new WebFilterExchange(this.exchange, this.chain), user).block();
this.strategy.onAuthenticationSuccess(new WebFilterExchange(this.exchange, this.chain), admin).block();
verify(this.handler).handle(this.contextCaptor.capture());
assertThat(this.contextCaptor.getValue().getMaximumSessionsAllowed()).isEqualTo(1);
assertThat(this.contextCaptor.getValue().getSessions()).isEqualTo(userSessions);
assertThat(this.contextCaptor.getValue().getAuthentication()).isEqualTo(user);
}
private ReactiveSessionInformation createSessionInformation(String sessionId) {
return new ReactiveSessionInformation(sessionId, "principal", Instant.now());
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.session;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.TestAuthentication;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.InMemoryReactiveSessionRegistry;
import org.springframework.security.core.session.ReactiveSessionInformation;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link InMemoryReactiveSessionRegistry}.
*/
class InMemoryReactiveSessionRegistryTests {
InMemoryReactiveSessionRegistry sessionRegistry = new InMemoryReactiveSessionRegistry();
Instant now = LocalDate.of(2023, 11, 21).atStartOfDay().toInstant(ZoneOffset.UTC);
@Test
void saveWhenPrincipalThenRegisterPrincipalSession() {
Authentication authentication = TestAuthentication.authenticatedUser();
ReactiveSessionInformation sessionInformation = new ReactiveSessionInformation(authentication.getPrincipal(),
"1234", this.now);
this.sessionRegistry.saveSessionInformation(sessionInformation).block();
List<ReactiveSessionInformation> principalSessions = this.sessionRegistry
.getAllSessions(authentication.getPrincipal(), false)
.collectList()
.block();
assertThat(principalSessions).hasSize(1);
assertThat(this.sessionRegistry.getSessionInformation("1234").block()).isNotNull();
}
@Test
void getAllSessionsWhenMultipleSessionsThenReturnAll() {
Authentication authentication = TestAuthentication.authenticatedUser();
ReactiveSessionInformation sessionInformation1 = new ReactiveSessionInformation(authentication.getPrincipal(),
"1234", this.now);
ReactiveSessionInformation sessionInformation2 = new ReactiveSessionInformation(authentication.getPrincipal(),
"4321", this.now);
ReactiveSessionInformation sessionInformation3 = new ReactiveSessionInformation(authentication.getPrincipal(),
"9876", this.now);
this.sessionRegistry.saveSessionInformation(sessionInformation1).block();
this.sessionRegistry.saveSessionInformation(sessionInformation2).block();
this.sessionRegistry.saveSessionInformation(sessionInformation3).block();
List<ReactiveSessionInformation> sessions = this.sessionRegistry
.getAllSessions(authentication.getPrincipal(), false)
.collectList()
.block();
assertThat(sessions).hasSize(3);
assertThat(this.sessionRegistry.getSessionInformation("1234").block()).isNotNull();
assertThat(this.sessionRegistry.getSessionInformation("4321").block()).isNotNull();
assertThat(this.sessionRegistry.getSessionInformation("9876").block()).isNotNull();
}
@Test
void removeSessionInformationThenSessionIsRemoved() {
Authentication authentication = TestAuthentication.authenticatedUser();
ReactiveSessionInformation sessionInformation = new ReactiveSessionInformation(authentication.getPrincipal(),
"1234", this.now);
this.sessionRegistry.saveSessionInformation(sessionInformation).block();
this.sessionRegistry.removeSessionInformation("1234").block();
List<ReactiveSessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getName(), false)
.collectList()
.block();
assertThat(this.sessionRegistry.getSessionInformation("1234").block()).isNull();
assertThat(sessions).isEmpty();
}
@Test
void updateLastAccessTimeThenUpdated() {
Authentication authentication = TestAuthentication.authenticatedUser();
ReactiveSessionInformation sessionInformation = new ReactiveSessionInformation(authentication.getPrincipal(),
"1234", this.now);
this.sessionRegistry.saveSessionInformation(sessionInformation).block();
ReactiveSessionInformation saved = this.sessionRegistry.getSessionInformation("1234").block();
assertThat(saved.getLastAccessTime()).isNotNull();
Instant lastAccessTimeBefore = saved.getLastAccessTime();
this.sessionRegistry.updateLastAccessTime("1234").block();
saved = this.sessionRegistry.getSessionInformation("1234").block();
assertThat(saved.getLastAccessTime()).isNotNull();
assertThat(saved.getLastAccessTime()).isAfter(lastAccessTimeBefore);
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.session;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.ReactiveSessionInformation;
import org.springframework.security.web.server.authentication.InvalidateLeastUsedServerMaximumSessionsExceededHandler;
import org.springframework.security.web.server.authentication.MaximumSessionsContext;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
* Tests for {@link InvalidateLeastUsedServerMaximumSessionsExceededHandler}
*
* @author Marcus da Coregio
*/
class InvalidateLeastUsedServerMaximumSessionsExceededHandlerTests {
InvalidateLeastUsedServerMaximumSessionsExceededHandler handler = new InvalidateLeastUsedServerMaximumSessionsExceededHandler();
@Test
void handleWhenInvokedThenInvalidatesLeastRecentlyUsedSessions() {
ReactiveSessionInformation session1 = mock(ReactiveSessionInformation.class);
ReactiveSessionInformation session2 = mock(ReactiveSessionInformation.class);
given(session1.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760010L));
given(session2.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760000L));
given(session2.invalidate()).willReturn(Mono.empty());
MaximumSessionsContext context = new MaximumSessionsContext(mock(Authentication.class),
List.of(session1, session2), 2);
this.handler.handle(context).block();
verify(session2).invalidate();
verify(session1).getLastAccessTime(); // used by comparator to sort the sessions
verify(session2).getLastAccessTime(); // used by comparator to sort the sessions
verifyNoMoreInteractions(session2);
verifyNoMoreInteractions(session1);
}
@Test
void handleWhenMoreThanOneSessionToInvalidateThenInvalidatesAllOfThem() {
ReactiveSessionInformation session1 = mock(ReactiveSessionInformation.class);
ReactiveSessionInformation session2 = mock(ReactiveSessionInformation.class);
ReactiveSessionInformation session3 = mock(ReactiveSessionInformation.class);
given(session1.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760010L));
given(session2.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760020L));
given(session3.getLastAccessTime()).willReturn(Instant.ofEpochMilli(1700827760030L));
given(session1.invalidate()).willReturn(Mono.empty());
given(session2.invalidate()).willReturn(Mono.empty());
MaximumSessionsContext context = new MaximumSessionsContext(mock(Authentication.class),
List.of(session1, session2, session3), 2);
this.handler.handle(context).block();
// @formatter:off
verify(session1).invalidate();
verify(session2).invalidate();
verify(session1, atLeastOnce()).getLastAccessTime(); // used by comparator to sort the sessions
verify(session2, atLeastOnce()).getLastAccessTime(); // used by comparator to sort the sessions
verify(session3, atLeastOnce()).getLastAccessTime(); // used by comparator to sort the sessions
verifyNoMoreInteractions(session1);
verifyNoMoreInteractions(session2);
verifyNoMoreInteractions(session3);
// @formatter:on
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.session;
import java.util.Collections;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.TestAuthentication;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.security.web.server.authentication.MaximumSessionsContext;
import org.springframework.security.web.server.authentication.PreventLoginServerMaximumSessionsExceededHandler;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link PreventLoginServerMaximumSessionsExceededHandler}.
*
* @author Marcus da Coregio
*/
class PreventLoginServerMaximumSessionsExceededHandlerTests {
@Test
void handleWhenInvokedThenThrowsSessionAuthenticationException() {
PreventLoginServerMaximumSessionsExceededHandler handler = new PreventLoginServerMaximumSessionsExceededHandler();
MaximumSessionsContext context = new MaximumSessionsContext(TestAuthentication.authenticatedUser(),
Collections.emptyList(), 1);
assertThatExceptionOfType(SessionAuthenticationException.class)
.isThrownBy(() -> handler.handle(context).block())
.withMessage("Maximum sessions of 1 for authentication 'user' exceeded");
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.session;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.mock.web.server.MockWebSession;
import org.springframework.security.authentication.TestAuthentication;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.ReactiveSessionInformation;
import org.springframework.security.core.session.ReactiveSessionRegistry;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.RegisterSessionServerAuthenticationSuccessHandler;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import org.springframework.web.server.WebSession;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class RegisterSessionServerAuthenticationSuccessHandlerTests {
@InjectMocks
RegisterSessionServerAuthenticationSuccessHandler strategy;
@Mock
ReactiveSessionRegistry sessionRegistry;
@Mock
WebFilterChain filterChain;
WebSession session = new MockWebSession();
ServerWebExchange serverWebExchange = MockServerWebExchange.builder(MockServerHttpRequest.get(""))
.session(this.session)
.build();
@Test
void constructorWhenSessionRegistryNullThenException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new RegisterSessionServerAuthenticationSuccessHandler(null))
.withMessage("sessionRegistry cannot be null");
}
@Test
void onAuthenticationWhenSessionExistsThenSaveSessionInformation() {
given(this.sessionRegistry.saveSessionInformation(any())).willReturn(Mono.empty());
WebFilterExchange webFilterExchange = new WebFilterExchange(this.serverWebExchange, this.filterChain);
Authentication authentication = TestAuthentication.authenticatedUser();
this.strategy.onAuthenticationSuccess(webFilterExchange, authentication).block();
ArgumentCaptor<ReactiveSessionInformation> captor = ArgumentCaptor.forClass(ReactiveSessionInformation.class);
verify(this.sessionRegistry).saveSessionInformation(captor.capture());
assertThat(captor.getValue().getSessionId()).isEqualTo(this.session.getId());
assertThat(captor.getValue().getLastAccessTime()).isEqualTo(this.session.getLastAccessTime());
assertThat(captor.getValue().getPrincipal()).isEqualTo(authentication.getPrincipal());
}
}

View File

@@ -0,0 +1,138 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.session;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.security.core.session.ReactiveSessionInformation;
import org.springframework.security.core.session.ReactiveSessionRegistry;
import org.springframework.web.server.WebSession;
import org.springframework.web.server.session.WebSessionStore;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link WebSessionStoreReactiveSessionRegistry}
*
* @author Marcus da Coregio
*/
class WebSessionStoreReactiveSessionRegistryTests {
WebSessionStore webSessionStore = mock();
WebSessionStoreReactiveSessionRegistry registry = new WebSessionStoreReactiveSessionRegistry(this.webSessionStore);
@Test
void constructorWhenWebSessionStoreNullThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> new WebSessionStoreReactiveSessionRegistry(null))
.withMessage("webSessionStore cannot be null");
}
@Test
void getSessionInformationWhenSavedThenReturnsWebSessionInformation() {
ReactiveSessionInformation session = createSession();
this.registry.saveSessionInformation(session).block();
ReactiveSessionInformation saved = this.registry.getSessionInformation(session.getSessionId()).block();
assertThat(saved).isInstanceOf(WebSessionStoreReactiveSessionRegistry.WebSessionInformation.class);
assertThat(saved.getPrincipal()).isEqualTo(session.getPrincipal());
assertThat(saved.getSessionId()).isEqualTo(session.getSessionId());
assertThat(saved.getLastAccessTime()).isEqualTo(session.getLastAccessTime());
}
@Test
void invalidateWhenReturnedFromGetSessionInformationThenWebSessionInvalidatedAndRemovedFromRegistry() {
ReactiveSessionInformation session = createSession();
WebSession webSession = mock();
given(webSession.invalidate()).willReturn(Mono.empty());
given(this.webSessionStore.retrieveSession(session.getSessionId())).willReturn(Mono.just(webSession));
this.registry.saveSessionInformation(session).block();
ReactiveSessionInformation saved = this.registry.getSessionInformation(session.getSessionId()).block();
saved.invalidate().block();
verify(webSession).invalidate();
assertThat(this.registry.getSessionInformation(saved.getSessionId()).block()).isNull();
}
@Test
void invalidateWhenReturnedFromRemoveSessionInformationThenWebSessionInvalidatedAndRemovedFromRegistry() {
ReactiveSessionInformation session = createSession();
WebSession webSession = mock();
given(webSession.invalidate()).willReturn(Mono.empty());
given(this.webSessionStore.retrieveSession(session.getSessionId())).willReturn(Mono.just(webSession));
this.registry.saveSessionInformation(session).block();
ReactiveSessionInformation saved = this.registry.removeSessionInformation(session.getSessionId()).block();
saved.invalidate().block();
verify(webSession).invalidate();
assertThat(this.registry.getSessionInformation(saved.getSessionId()).block()).isNull();
}
@Test
void invalidateWhenReturnedFromGetAllSessionsThenWebSessionInvalidatedAndRemovedFromRegistry() {
ReactiveSessionInformation session = createSession();
WebSession webSession = mock();
given(webSession.invalidate()).willReturn(Mono.empty());
given(this.webSessionStore.retrieveSession(session.getSessionId())).willReturn(Mono.just(webSession));
this.registry.saveSessionInformation(session).block();
List<ReactiveSessionInformation> saved = this.registry.getAllSessions(session.getPrincipal(), false)
.collectList()
.block();
saved.forEach((info) -> info.invalidate().block());
verify(webSession).invalidate();
assertThat(this.registry.getAllSessions(session.getPrincipal(), false).collectList().block()).isEmpty();
}
@Test
void setSessionRegistryThenUses() {
ReactiveSessionRegistry sessionRegistry = mock();
given(sessionRegistry.saveSessionInformation(any())).willReturn(Mono.empty());
given(sessionRegistry.removeSessionInformation(any())).willReturn(Mono.empty());
given(sessionRegistry.updateLastAccessTime(any())).willReturn(Mono.empty());
given(sessionRegistry.getSessionInformation(any())).willReturn(Mono.empty());
given(sessionRegistry.getAllSessions(any(), anyBoolean())).willReturn(Flux.empty());
this.registry.setSessionRegistry(sessionRegistry);
ReactiveSessionInformation session = createSession();
this.registry.saveSessionInformation(session).block();
verify(sessionRegistry).saveSessionInformation(any());
this.registry.removeSessionInformation(session.getSessionId()).block();
verify(sessionRegistry).removeSessionInformation(any());
this.registry.updateLastAccessTime(session.getSessionId()).block();
verify(sessionRegistry).updateLastAccessTime(any());
this.registry.getSessionInformation(session.getSessionId()).block();
verify(sessionRegistry).getSessionInformation(any());
this.registry.getAllSessions(session.getPrincipal(), false).blockFirst();
verify(sessionRegistry).getAllSessions(any(), eq(false));
}
private static ReactiveSessionInformation createSession() {
return new ReactiveSessionInformation("principal", "sessionId", Instant.now());
}
}