diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/SwitchUserWebFilter.java b/web/src/main/java/org/springframework/security/web/server/authentication/SwitchUserWebFilter.java
new file mode 100644
index 0000000000..f545b7bcc0
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/server/authentication/SwitchUserWebFilter.java
@@ -0,0 +1,385 @@
+/*
+ * Copyright 2002-2020 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 org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.http.HttpMethod;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
+import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextImpl;
+import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsChecker;
+import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority;
+import org.springframework.security.web.server.WebFilterExchange;
+import org.springframework.security.web.server.context.ServerSecurityContextRepository;
+import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
+import org.springframework.security.web.util.UrlUtils;
+import org.springframework.util.Assert;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Switch User processing filter responsible for user context switching.
+ * A common use-case for this feature is the ability to allow
+ * higher-authority users (e.g. ROLE_ADMIN) to switch to a regular user (e.g. ROLE_USER).
+ *
+ * This filter assumes that the user performing the switch will be required to be logged
+ * in as normal user (i.e. with a ROLE_ADMIN role). The user will then access a page/controller
+ * that enables the administrator to specify who they wish to become (see switchUserUrl
).
+ *
+ * Note: This URL will be required to have appropriate security constraints configured
+ * so that only users of that role can access it (e.g. ROLE_ADMIN).
+ *
+ * On a successful switch, the user's SecurityContext
will be updated to
+ * reflect the specified user and will also contain an additional
+ * {@link org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority}
+ * which contains the original user. Before switching, a check will be made on whether the
+ * user is already currently switched, and any current switch will be exited to prevent
+ * "nested" switches.
+ *
+ * To 'exit' from a user context, the user needs to access a URL (see exitUserUrl
)
+ * that will switch back to the original user as identified by the ROLE_PREVIOUS_ADMINISTRATOR
.
+ *
+ * To configure the Switch User Processing Filter, create a bean definition for the Switch
+ * User processing filter and add to the filterChainProxy. Note that the filter must come
+ * after the {@link org.springframework.security.config.web.server.SecurityWebFiltersOrder#AUTHORIZATION}
+ * in the chain, in order to apply the correct constraints to the switchUserUrl. Example:
+ *
+ * SwitchUserWebFilter filter = new SwitchUserWebFilter(userDetailsService, loginSuccessHandler, failureHandler);
+ * http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHORIZATION);
+ *
+ *
+ * @author Artur Otrzonsek
+ * @see SwitchUserGrantedAuthority
+ * @since 5.4
+ */
+public class SwitchUserWebFilter implements WebFilter {
+
+ private final Log logger = LogFactory.getLog(getClass());
+
+ public static final String SPRING_SECURITY_SWITCH_USERNAME_KEY = "username";
+ public static final String ROLE_PREVIOUS_ADMINISTRATOR = "ROLE_PREVIOUS_ADMINISTRATOR";
+
+ private final ServerAuthenticationSuccessHandler successHandler;
+ private final ServerAuthenticationFailureHandler failureHandler;
+ private final ReactiveUserDetailsService userDetailsService;
+ private final UserDetailsChecker userDetailsChecker;
+
+ private ServerSecurityContextRepository securityContextRepository;
+
+ private ServerWebExchangeMatcher switchUserMatcher = createMatcher("/login/impersonate");
+ private ServerWebExchangeMatcher exitUserMatcher = createMatcher("/logout/impersonate");
+
+ /**
+ * Creates a filter for the user context switching
+ *
+ * @param userDetailsService The UserDetailService which will be used to load
+ * information for the user that is being switched to.
+ * @param successHandler Used to define custom behaviour on a successful switch or exit user.
+ * @param failureHandler Used to define custom behaviour when a switch fails.
+ */
+ public SwitchUserWebFilter(ReactiveUserDetailsService userDetailsService,
+ ServerAuthenticationSuccessHandler successHandler,
+ @Nullable ServerAuthenticationFailureHandler failureHandler) {
+ Assert.notNull(userDetailsService, "userDetailsService must be specified");
+ Assert.notNull(successHandler, "successHandler must be specified");
+
+ this.userDetailsService = userDetailsService;
+ this.successHandler = successHandler;
+ this.failureHandler = failureHandler;
+
+ this.securityContextRepository = new WebSessionServerSecurityContextRepository();
+ this.userDetailsChecker = new AccountStatusUserDetailsChecker();
+ }
+
+ /**
+ * Creates a filter for the user context switching
+ *
+ * @param userDetailsService The UserDetailService which will be used to load
+ * information for the user that is being switched to.
+ * @param successTargetUrl Sets the URL to go to after a successful switch / exit user request
+ * @param failureTargetUrl The URL to which a user should be redirected if the switch fails
+ */
+ public SwitchUserWebFilter(ReactiveUserDetailsService userDetailsService,
+ String successTargetUrl, @Nullable String failureTargetUrl) {
+ Assert.notNull(userDetailsService, "userDetailsService must be specified");
+ Assert.notNull(successTargetUrl, "successTargetUrl must be specified");
+
+ this.userDetailsService = userDetailsService;
+ this.successHandler = new RedirectServerAuthenticationSuccessHandler(successTargetUrl);
+
+ if (failureTargetUrl != null) {
+ this.failureHandler = new RedirectServerAuthenticationFailureHandler(failureTargetUrl);
+ } else {
+ this.failureHandler = null;
+ }
+
+ this.securityContextRepository = new WebSessionServerSecurityContextRepository();
+ this.userDetailsChecker = new AccountStatusUserDetailsChecker();
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) {
+ final WebFilterExchange webFilterExchange = new WebFilterExchange(exchange, chain);
+
+ return switchUser(webFilterExchange)
+ .switchIfEmpty(Mono.defer(() -> exitSwitchUser(webFilterExchange)))
+ .switchIfEmpty(Mono.defer(() -> chain.filter(exchange).then(Mono.empty())))
+ .flatMap(authentication -> onAuthenticationSuccess(authentication, webFilterExchange))
+ .onErrorResume(SwitchUserAuthenticationException.class, exception -> Mono.empty());
+ }
+
+ /**
+ * Attempt to switch to another user.
+ *
+ * @param webFilterExchange The web filter exchange
+ * @return The new Authentication
object if successfully switched to
+ * another user, Mono.empty()
otherwise.
+ * @throws AuthenticationCredentialsNotFoundException If the target user can not be found by username
+ */
+ protected Mono switchUser(WebFilterExchange webFilterExchange) {
+ return switchUserMatcher.matches(webFilterExchange.getExchange())
+ .filter(ServerWebExchangeMatcher.MatchResult::isMatch)
+ .flatMap(matchResult -> ReactiveSecurityContextHolder.getContext())
+ .map(SecurityContext::getAuthentication)
+ .flatMap(currentAuthentication -> {
+ final String username = getUsername(webFilterExchange.getExchange());
+ return attemptSwitchUser(currentAuthentication, username);
+ })
+ .onErrorResume(AuthenticationException.class, e ->
+ onAuthenticationFailure(e, webFilterExchange)
+ .then(Mono.error(new SwitchUserAuthenticationException(e)))
+ );
+ }
+
+ /**
+ * Attempt to exit from an already switched user.
+ *
+ * @param webFilterExchange The web filter exchange
+ * @return The original Authentication
object.
+ * @throws AuthenticationCredentialsNotFoundException If there is no Authentication
associated
+ * with this request or the user is not switched.
+ */
+ protected Mono exitSwitchUser(WebFilterExchange webFilterExchange) {
+ return exitUserMatcher.matches(webFilterExchange.getExchange())
+ .filter(ServerWebExchangeMatcher.MatchResult::isMatch)
+ .flatMap(matchResult ->
+ ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .switchIfEmpty(Mono.error(this::noCurrentUserException))
+ )
+ .map(this::attemptExitUser);
+ }
+
+ /**
+ * Returns the name of the target user.
+ *
+ * @param exchange The server web exchange
+ * @return the name of the target user.
+ */
+ protected String getUsername(ServerWebExchange exchange) {
+ return exchange.getRequest().getQueryParams().getFirst(SPRING_SECURITY_SWITCH_USERNAME_KEY);
+ }
+
+ @NonNull
+ private Mono attemptSwitchUser(Authentication currentAuthentication, String userName) {
+ Assert.notNull(userName, "The userName can not be null.");
+
+ if (this.logger.isDebugEnabled()) {
+ this.logger.debug("Attempt to switch to user [" + userName + "]");
+ }
+
+ return userDetailsService.findByUsername(userName)
+ .switchIfEmpty(Mono.error(this::noTargetAuthenticationException))
+ .doOnNext(userDetailsChecker::check)
+ .map(userDetails -> createSwitchUserToken(userDetails, currentAuthentication));
+ }
+
+ @NonNull
+ private Authentication attemptExitUser(Authentication currentAuthentication) {
+ final Optional sourceAuthentication = extractSourceAuthentication(currentAuthentication);
+
+ if (!sourceAuthentication.isPresent()) {
+ this.logger.debug("Could not find original user Authentication object!");
+ throw noOriginalAuthenticationException();
+ }
+
+ return sourceAuthentication.get();
+ }
+
+ private Mono onAuthenticationSuccess(Authentication authentication, WebFilterExchange webFilterExchange) {
+ final ServerWebExchange exchange = webFilterExchange.getExchange();
+ final SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
+ return securityContextRepository.save(exchange, securityContext)
+ .then(this.successHandler.onAuthenticationSuccess(webFilterExchange, authentication))
+ .subscriberContext(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext)));
+ }
+
+ private Mono onAuthenticationFailure(AuthenticationException exception,
+ WebFilterExchange webFilterExchange) {
+ return Mono.justOrEmpty(failureHandler)
+ .switchIfEmpty(Mono.defer(() -> {
+ logger.error("Switch User failed", exception);
+ return Mono.error(exception);
+ }))
+ .flatMap(failureHandler -> failureHandler.onAuthenticationFailure(webFilterExchange, exception));
+ }
+
+ private Authentication createSwitchUserToken(UserDetails targetUser, Authentication currentAuthentication) {
+ final Optional sourceAuthentication = extractSourceAuthentication(currentAuthentication);
+
+ if (sourceAuthentication.isPresent()) {
+ // SEC-1763. Check first if we are already switched.
+ logger.info("Found original switch user granted authority [" + sourceAuthentication.get() + "]");
+ currentAuthentication = sourceAuthentication.get();
+ }
+
+ final GrantedAuthority switchAuthority =
+ new SwitchUserGrantedAuthority(ROLE_PREVIOUS_ADMINISTRATOR, currentAuthentication);
+ final Collection extends GrantedAuthority> targetUserAuthorities = targetUser.getAuthorities();
+
+ final List extendedTargetUserAuthorities = new ArrayList<>(targetUserAuthorities);
+ extendedTargetUserAuthorities.add(switchAuthority);
+
+ return new UsernamePasswordAuthenticationToken(
+ targetUser, targetUser.getPassword(), extendedTargetUserAuthorities
+ );
+ }
+
+ /**
+ * Find the original Authentication
object from the current user's
+ * granted authorities. A successfully switched user should have a
+ * SwitchUserGrantedAuthority
that contains the original source user
+ * Authentication
object.
+ *
+ * @param currentAuthentication The current Authentication
object
+ * @return The source user Authentication
object or Optional.empty
+ * otherwise.
+ */
+ private Optional extractSourceAuthentication(Authentication currentAuthentication) {
+ // iterate over granted authorities and find the 'switch user' authority
+ for (GrantedAuthority authority : currentAuthentication.getAuthorities()) {
+ if (authority instanceof SwitchUserGrantedAuthority) {
+ final SwitchUserGrantedAuthority switchAuthority = (SwitchUserGrantedAuthority) authority;
+ return Optional.of(switchAuthority.getSource());
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static ServerWebExchangeMatcher createMatcher(String pattern) {
+ return ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, pattern);
+ }
+
+ private AuthenticationCredentialsNotFoundException noCurrentUserException() {
+ return new AuthenticationCredentialsNotFoundException(
+ "No current user associated with this request"
+ );
+ }
+
+ private AuthenticationCredentialsNotFoundException noOriginalAuthenticationException() {
+ return new AuthenticationCredentialsNotFoundException(
+ "Could not find original Authentication object"
+ );
+ }
+
+ private AuthenticationCredentialsNotFoundException noTargetAuthenticationException() {
+ return new AuthenticationCredentialsNotFoundException(
+ "No target user for the given username"
+ );
+ }
+
+ private static class SwitchUserAuthenticationException extends RuntimeException {
+ SwitchUserAuthenticationException(AuthenticationException exception) {
+ super(exception);
+ }
+ }
+
+ /**
+ * Sets the repository for persisting the SecurityContext. Default is {@link WebSessionServerSecurityContextRepository}
+ *
+ * @param securityContextRepository the repository to use
+ */
+ public void setSecurityContextRepository(
+ ServerSecurityContextRepository securityContextRepository) {
+ Assert.notNull(securityContextRepository, "securityContextRepository cannot be null");
+ this.securityContextRepository = securityContextRepository;
+ }
+
+ /**
+ * Set the URL to respond to exit user processing. This is a shortcut for
+ * * {@link #setExitUserMatcher(ServerWebExchangeMatcher)}
+ *
+ * @param exitUserUrl The exit user URL.
+ */
+ public void setExitUserUrl(String exitUserUrl) {
+ Assert.isTrue(UrlUtils.isValidRedirectUrl(exitUserUrl),
+ "exitUserUrl cannot be empty and must be a valid redirect URL");
+ this.exitUserMatcher = createMatcher(exitUserUrl);
+ }
+
+ /**
+ * Set the matcher to respond to exit user processing.
+ *
+ * @param exitUserMatcher The exit matcher to use
+ */
+ public void setExitUserMatcher(ServerWebExchangeMatcher exitUserMatcher) {
+ Assert.notNull(exitUserMatcher, "exitUserMatcher cannot be null");
+ this.exitUserMatcher = exitUserMatcher;
+ }
+
+ /**
+ * Set the URL to respond to switch user processing. This is a shortcut for
+ * {@link #setSwitchUserMatcher(ServerWebExchangeMatcher)}
+ *
+ * @param switchUserUrl The switch user URL.
+ */
+ public void setSwitchUserUrl(String switchUserUrl) {
+ Assert.isTrue(UrlUtils.isValidRedirectUrl(switchUserUrl),
+ "switchUserUrl cannot be empty and must be a valid redirect URL");
+ this.switchUserMatcher = createMatcher(switchUserUrl);
+ }
+
+ /**
+ * Set the matcher to respond to switch user processing.
+ *
+ * @param switchUserMatcher The switch user matcher.
+ */
+ public void setSwitchUserMatcher(ServerWebExchangeMatcher switchUserMatcher) {
+ Assert.notNull(switchUserMatcher, "switchUserMatcher cannot be null");
+ this.switchUserMatcher = switchUserMatcher;
+ }
+}
diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/SwitchUserWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/SwitchUserWebFilterTests.java
new file mode 100644
index 0000000000..3c4f36bb42
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/server/authentication/SwitchUserWebFilterTests.java
@@ -0,0 +1,656 @@
+/*
+ * Copyright 2002-2020 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 org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.http.HttpMethod;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
+import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
+import org.springframework.security.authentication.DisabledException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextImpl;
+import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority;
+import org.springframework.security.web.server.WebFilterExchange;
+import org.springframework.security.web.server.context.ServerSecurityContextRepository;
+import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
+
+import java.security.Principal;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+import static org.springframework.security.core.context.ReactiveSecurityContextHolder.withSecurityContext;
+import static org.springframework.security.web.server.authentication.SwitchUserWebFilter.ROLE_PREVIOUS_ADMINISTRATOR;
+
+/**
+ * @author Artur Otrzonsek
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class SwitchUserWebFilterTests {
+
+ private SwitchUserWebFilter switchUserWebFilter;
+
+ @Mock
+ private ReactiveUserDetailsService userDetailsService;
+ @Mock
+ ServerAuthenticationSuccessHandler successHandler;
+ @Mock
+ private ServerAuthenticationFailureHandler failureHandler;
+ @Mock
+ private ServerSecurityContextRepository serverSecurityContextRepository;
+
+ @Rule
+ public ExpectedException exceptionRule = ExpectedException.none();
+
+ @Before
+ public void setUp() {
+ switchUserWebFilter = new SwitchUserWebFilter(userDetailsService, successHandler, failureHandler);
+ switchUserWebFilter.setSecurityContextRepository(serverSecurityContextRepository);
+ }
+
+ @Test
+ public void switchUserWhenRequestNotMatchThenDoesNothing() {
+ // given
+ MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/not/existing"));
+
+ WebFilterChain chain = mock(WebFilterChain.class);
+ when(chain.filter(exchange)).thenReturn(Mono.empty());
+
+ // when
+ switchUserWebFilter.filter(exchange, chain).block();
+ // then
+ verifyNoInteractions(userDetailsService);
+ verifyNoInteractions(successHandler);
+ verifyNoInteractions(failureHandler);
+ verifyNoInteractions(serverSecurityContextRepository);
+
+ verify(chain).filter(exchange);
+ }
+
+ @Test
+ public void switchUser() {
+ // given
+ final String targetUsername = "TEST_USERNAME";
+ final UserDetails switchUserDetails = switchUserDetails(targetUsername, true);
+
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/login/impersonate?username={targetUser}", targetUsername));
+
+ final WebFilterChain chain = mock(WebFilterChain.class);
+
+ final Authentication originalAuthentication =
+ new UsernamePasswordAuthenticationToken("principal", "credentials");
+ final SecurityContextImpl securityContext = new SecurityContextImpl(originalAuthentication);
+
+ when(userDetailsService.findByUsername(targetUsername))
+ .thenReturn(Mono.just(switchUserDetails));
+ when(serverSecurityContextRepository.save(eq(exchange), any(SecurityContext.class)))
+ .thenReturn(Mono.empty());
+ when(successHandler.onAuthenticationSuccess(any(WebFilterExchange.class), any(Authentication.class)))
+ .thenReturn(Mono.empty());
+
+ // when
+ switchUserWebFilter.filter(exchange, chain)
+ .subscriberContext(withSecurityContext(Mono.just(securityContext)))
+ .block();
+
+ // then
+ verifyNoInteractions(chain);
+ verify(userDetailsService).findByUsername(targetUsername);
+
+ final ArgumentCaptor securityContextCaptor = ArgumentCaptor.forClass(SecurityContext.class);
+ verify(serverSecurityContextRepository).save(eq(exchange), securityContextCaptor.capture());
+ final SecurityContext savedSecurityContext = securityContextCaptor.getValue();
+
+ final ArgumentCaptor authenticationCaptor = ArgumentCaptor.forClass(Authentication.class);
+ verify(successHandler).onAuthenticationSuccess(any(WebFilterExchange.class), authenticationCaptor.capture());
+
+ final Authentication switchUserAuthentication = authenticationCaptor.getValue();
+
+ assertSame(savedSecurityContext.getAuthentication(), switchUserAuthentication);
+
+ assertEquals("username should point to the switched user",
+ targetUsername, switchUserAuthentication.getName());
+ assertTrue("switchAuthentication should contain SwitchUserGrantedAuthority",
+ switchUserAuthentication.getAuthorities().stream()
+ .anyMatch(a -> a instanceof SwitchUserGrantedAuthority)
+ );
+ assertTrue("new authentication should get new role ",
+ switchUserAuthentication.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .anyMatch(a -> a.equals(ROLE_PREVIOUS_ADMINISTRATOR))
+ );
+ assertEquals(
+ "SwitchUserGrantedAuthority should contain the original authentication",
+ originalAuthentication.getName(),
+ switchUserAuthentication.getAuthorities().stream()
+ .filter(a -> a instanceof SwitchUserGrantedAuthority)
+ .map(a -> ((SwitchUserGrantedAuthority) a).getSource())
+ .map(Principal::getName)
+ .findFirst()
+ .orElse(null)
+ );
+ }
+
+ @Test
+ public void switchUserWhenUserAlreadySwitchedThenExitSwitchAndSwitchAgain() {
+ // given
+ final Authentication originalAuthentication =
+ new UsernamePasswordAuthenticationToken("origPrincipal", "origCredentials");
+
+ final GrantedAuthority switchAuthority =
+ new SwitchUserGrantedAuthority(ROLE_PREVIOUS_ADMINISTRATOR, originalAuthentication);
+ final Authentication switchUserAuthentication =
+ new UsernamePasswordAuthenticationToken("switchPrincipal", "switchCredentials",
+ Collections.singleton(switchAuthority));
+
+ final SecurityContextImpl securityContext = new SecurityContextImpl(switchUserAuthentication);
+
+ final String targetUsername = "newSwitchPrincipal";
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/login/impersonate?username={targetUser}", targetUsername));
+
+ final WebFilterChain chain = mock(WebFilterChain.class);
+
+ when(serverSecurityContextRepository.save(eq(exchange), any(SecurityContext.class)))
+ .thenReturn(Mono.empty());
+ when(successHandler.onAuthenticationSuccess(any(WebFilterExchange.class), any(Authentication.class)))
+ .thenReturn(Mono.empty());
+ when(userDetailsService.findByUsername(targetUsername))
+ .thenReturn(Mono.just(switchUserDetails(targetUsername, true)));
+
+ // when
+ switchUserWebFilter.filter(exchange, chain)
+ .subscriberContext(withSecurityContext(Mono.just(securityContext)))
+ .block();
+
+ // then
+ final ArgumentCaptor authenticationCaptor = ArgumentCaptor.forClass(Authentication.class);
+ verify(successHandler).onAuthenticationSuccess(any(WebFilterExchange.class), authenticationCaptor.capture());
+
+ final Authentication secondSwitchUserAuthentication = authenticationCaptor.getValue();
+
+ assertEquals("username should point to the switched user",
+ targetUsername, secondSwitchUserAuthentication.getName());
+ assertEquals(
+ "SwitchUserGrantedAuthority should contain the original authentication",
+ originalAuthentication.getName(),
+ secondSwitchUserAuthentication.getAuthorities().stream()
+ .filter(a -> a instanceof SwitchUserGrantedAuthority)
+ .map(a -> ((SwitchUserGrantedAuthority) a).getSource())
+ .map(Principal::getName)
+ .findFirst()
+ .orElse(null)
+ );
+ }
+
+ @Test
+ public void switchUserWhenUsernameIsMissingThenThrowException() {
+ // given
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/login/impersonate"));
+
+ final WebFilterChain chain = mock(WebFilterChain.class);
+ final SecurityContextImpl securityContext = new SecurityContextImpl(mock(Authentication.class));
+
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("The userName can not be null.");
+
+ // when
+ switchUserWebFilter.filter(exchange, chain)
+ .subscriberContext(withSecurityContext(Mono.just(securityContext)))
+ .block();
+ verifyNoInteractions(chain);
+ }
+
+ @Test
+ public void switchUserWhenExceptionThenCallFailureHandler() {
+ final String targetUsername = "TEST_USERNAME";
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/login/impersonate?username={targetUser}", targetUsername));
+
+ final WebFilterChain chain = mock(WebFilterChain.class);
+ final SecurityContextImpl securityContext = new SecurityContextImpl(mock(Authentication.class));
+
+ final UserDetails switchUserDetails = switchUserDetails(targetUsername, false);
+ when(userDetailsService.findByUsername(any(String.class))).thenReturn(Mono.just(switchUserDetails));
+ when(failureHandler.onAuthenticationFailure(any(WebFilterExchange.class), any(DisabledException.class)))
+ .thenReturn(Mono.empty());
+
+ // when
+ switchUserWebFilter.filter(exchange, chain)
+ .subscriberContext(withSecurityContext(Mono.just(securityContext)))
+ .block();
+
+ verify(failureHandler).onAuthenticationFailure(any(WebFilterExchange.class), any(DisabledException.class));
+ verifyNoInteractions(chain);
+ }
+
+ @Test
+ public void switchUserWhenFailureHandlerNotDefinedThenReturnError() {
+ // given
+ switchUserWebFilter = new SwitchUserWebFilter(userDetailsService, successHandler, null);
+
+ final String targetUsername = "TEST_USERNAME";
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/login/impersonate?username={targetUser}", targetUsername));
+
+ final WebFilterChain chain = mock(WebFilterChain.class);
+ final SecurityContextImpl securityContext = new SecurityContextImpl(mock(Authentication.class));
+
+ final UserDetails switchUserDetails = switchUserDetails(targetUsername, false);
+ when(userDetailsService.findByUsername(any(String.class))).thenReturn(Mono.just(switchUserDetails));
+
+ exceptionRule.expect(DisabledException.class);
+
+ // when then
+ switchUserWebFilter.filter(exchange, chain)
+ .subscriberContext(withSecurityContext(Mono.just(securityContext)))
+ .block();
+ verifyNoInteractions(chain);
+ }
+
+ @Test
+ public void exitSwitchThenReturnToOriginalAuthentication() {
+ // given
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/logout/impersonate"));
+
+ final Authentication originalAuthentication =
+ new UsernamePasswordAuthenticationToken("origPrincipal", "origCredentials");
+
+ final GrantedAuthority switchAuthority =
+ new SwitchUserGrantedAuthority(ROLE_PREVIOUS_ADMINISTRATOR, originalAuthentication);
+ final Authentication switchUserAuthentication =
+ new UsernamePasswordAuthenticationToken("switchPrincipal", "switchCredentials",
+ Collections.singleton(switchAuthority));
+
+ final WebFilterChain chain = mock(WebFilterChain.class);
+ final SecurityContextImpl securityContext = new SecurityContextImpl(switchUserAuthentication);
+
+ when(serverSecurityContextRepository.save(eq(exchange), any(SecurityContext.class)))
+ .thenReturn(Mono.empty());
+ when(successHandler.onAuthenticationSuccess(any(WebFilterExchange.class), any(Authentication.class)))
+ .thenReturn(Mono.empty());
+
+ // when
+ switchUserWebFilter.filter(exchange, chain)
+ .subscriberContext(withSecurityContext(Mono.just(securityContext)))
+ .block();
+
+ // then
+ final ArgumentCaptor securityContextCaptor = ArgumentCaptor.forClass(SecurityContext.class);
+ verify(serverSecurityContextRepository).save(eq(exchange), securityContextCaptor.capture());
+ final SecurityContext savedSecurityContext = securityContextCaptor.getValue();
+
+ final ArgumentCaptor authenticationCaptor = ArgumentCaptor.forClass(Authentication.class);
+ verify(successHandler).onAuthenticationSuccess(any(WebFilterExchange.class), authenticationCaptor.capture());
+
+ final Authentication originalAuthenticationValue = authenticationCaptor.getValue();
+
+ assertSame(originalAuthentication, savedSecurityContext.getAuthentication());
+ assertSame(originalAuthentication, originalAuthenticationValue);
+ verifyNoInteractions(chain);
+ }
+
+ @Test
+ public void exitSwitchWhenUserNotSwitchedThenThrowError() {
+ // given
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/logout/impersonate"));
+
+ final Authentication originalAuthentication =
+ new UsernamePasswordAuthenticationToken("origPrincipal", "origCredentials");
+
+ final WebFilterChain chain = mock(WebFilterChain.class);
+ final SecurityContextImpl securityContext = new SecurityContextImpl(originalAuthentication);
+
+ exceptionRule.expect(AuthenticationCredentialsNotFoundException.class);
+ exceptionRule.expectMessage("Could not find original Authentication object");
+
+ // when then
+ switchUserWebFilter.filter(exchange, chain)
+ .subscriberContext(withSecurityContext(Mono.just(securityContext)))
+ .block();
+ verifyNoInteractions(chain);
+ }
+
+ @Test
+ public void exitSwitchWhenNoCurrentUserThenThrowError() {
+ // given
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/logout/impersonate"));
+
+ final WebFilterChain chain = mock(WebFilterChain.class);
+
+ exceptionRule.expect(AuthenticationCredentialsNotFoundException.class);
+ exceptionRule.expectMessage("No current user associated with this request");
+
+ // when
+ switchUserWebFilter.filter(exchange, chain).block();
+ //then
+ verifyNoInteractions(chain);
+ }
+
+ @Test
+ public void constructorUserDetailsServiceRequired() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("userDetailsService must be specified");
+
+ // when
+ switchUserWebFilter = new SwitchUserWebFilter(
+ null,
+ mock(ServerAuthenticationSuccessHandler.class),
+ mock(ServerAuthenticationFailureHandler.class)
+ );
+ }
+
+ @Test
+ public void constructorServerAuthenticationSuccessHandlerRequired() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("successHandler must be specified");
+ // when
+ switchUserWebFilter = new SwitchUserWebFilter(
+ mock(ReactiveUserDetailsService.class),
+ null,
+ mock(ServerAuthenticationFailureHandler.class)
+ );
+ }
+
+ @Test
+ public void constructorSuccessTargetUrlRequired() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("successTargetUrl must be specified");
+ // when
+ switchUserWebFilter = new SwitchUserWebFilter(
+ mock(ReactiveUserDetailsService.class),
+ null,
+ "failure/target/url"
+ );
+ }
+
+ @Test
+ public void constructorFirstDefaultValues() {
+ // when
+ switchUserWebFilter = new SwitchUserWebFilter(
+ mock(ReactiveUserDetailsService.class),
+ mock(ServerAuthenticationSuccessHandler.class),
+ mock(ServerAuthenticationFailureHandler.class)
+ );
+
+ // then
+ final Object securityContextRepository =
+ ReflectionTestUtils.getField(switchUserWebFilter, "securityContextRepository");
+ assertTrue(securityContextRepository instanceof WebSessionServerSecurityContextRepository);
+
+ final Object userDetailsChecker =
+ ReflectionTestUtils.getField(switchUserWebFilter, "userDetailsChecker");
+ assertTrue(userDetailsChecker instanceof AccountStatusUserDetailsChecker);
+ }
+
+ @Test
+ public void constructorSecondDefaultValues() {
+ // when
+ switchUserWebFilter = new SwitchUserWebFilter(
+ mock(ReactiveUserDetailsService.class),
+ "success/target/url",
+ "failure/target/url"
+ );
+
+ // then
+ final Object successHandler =
+ ReflectionTestUtils.getField(switchUserWebFilter, "successHandler");
+ assertTrue(successHandler instanceof RedirectServerAuthenticationSuccessHandler);
+
+ final Object failureHandler =
+ ReflectionTestUtils.getField(switchUserWebFilter, "failureHandler");
+ assertTrue(failureHandler instanceof RedirectServerAuthenticationFailureHandler);
+
+ final Object securityContextRepository =
+ ReflectionTestUtils.getField(switchUserWebFilter, "securityContextRepository");
+ assertTrue(securityContextRepository instanceof WebSessionServerSecurityContextRepository);
+
+ final Object userDetailsChecker =
+ ReflectionTestUtils.getField(switchUserWebFilter, "userDetailsChecker");
+ assertTrue(userDetailsChecker instanceof AccountStatusUserDetailsChecker);
+ }
+
+ @Test
+ public void setSecurityContextRepositoryWhenNullThenThrowException() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("securityContextRepository cannot be null");
+ // when
+ switchUserWebFilter.setSecurityContextRepository(null);
+ // then
+ fail("Test should fail with exception");
+ }
+
+ @Test
+ public void setSecurityContextRepositoryWhenDefinedThenChangeDefaultValue() {
+ // given
+ final Object oldSecurityContextRepository =
+ ReflectionTestUtils.getField(switchUserWebFilter, "securityContextRepository");
+ assertSame(serverSecurityContextRepository, oldSecurityContextRepository);
+
+ final ServerSecurityContextRepository newSecurityContextRepository = mock(ServerSecurityContextRepository.class);
+ // when
+ switchUserWebFilter.setSecurityContextRepository(newSecurityContextRepository);
+ // then
+ final Object currentSecurityContextRepository =
+ ReflectionTestUtils.getField(switchUserWebFilter, "securityContextRepository");
+ assertSame(newSecurityContextRepository, currentSecurityContextRepository);
+ }
+
+ @Test
+ public void setExitUserUrlWhenNullThenThrowException() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("exitUserUrl cannot be empty and must be a valid redirect URL");
+ // when
+ switchUserWebFilter.setExitUserUrl(null);
+ // then
+ fail("Test should fail with exception");
+ }
+
+ @Test
+ public void setExitUserUrlWhenInvalidUrlThenThrowException() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("exitUserUrl cannot be empty and must be a valid redirect URL");
+ // when
+ switchUserWebFilter.setExitUserUrl("wrongUrl");
+ // then
+ fail("Test should fail with exception");
+ }
+
+ @Test
+ public void setExitUserUrlWhenDefinedThenChangeDefaultValue() {
+ // given
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/logout/impersonate"));
+
+ final ServerWebExchangeMatcher oldExitUserMatcher =
+ (ServerWebExchangeMatcher) ReflectionTestUtils.getField(switchUserWebFilter, "exitUserMatcher");
+
+ assertThat(oldExitUserMatcher.matches(exchange).block().isMatch()).isTrue();
+
+ // when
+ switchUserWebFilter.setExitUserUrl("/exit-url");
+
+ // then
+ final MockServerWebExchange newExchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/exit-url"));
+
+ final ServerWebExchangeMatcher newExitUserMatcher =
+ (ServerWebExchangeMatcher) ReflectionTestUtils.getField(switchUserWebFilter, "exitUserMatcher");
+
+ assertThat(newExitUserMatcher.matches(newExchange).block().isMatch()).isTrue();
+ }
+
+ @Test
+ public void setExitUserMatcherWhenNullThenThrowException() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("exitUserMatcher cannot be null");
+ // when
+ switchUserWebFilter.setExitUserMatcher(null);
+ // then
+ fail("Test should fail with exception");
+ }
+
+ @Test
+ public void setExitUserMatcherWhenDefinedThenChangeDefaultValue() {
+ // given
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/logout/impersonate"));
+
+ final ServerWebExchangeMatcher oldExitUserMatcher =
+ (ServerWebExchangeMatcher) ReflectionTestUtils.getField(switchUserWebFilter, "exitUserMatcher");
+
+ assertThat(oldExitUserMatcher.matches(exchange).block().isMatch()).isTrue();
+
+ final ServerWebExchangeMatcher newExitUserMatcher =
+ ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/exit-url");
+
+ // when
+ switchUserWebFilter.setExitUserMatcher(newExitUserMatcher);
+
+ // then
+
+ final ServerWebExchangeMatcher currentExitUserMatcher =
+ (ServerWebExchangeMatcher) ReflectionTestUtils.getField(switchUserWebFilter, "exitUserMatcher");
+
+ assertSame(newExitUserMatcher, currentExitUserMatcher);
+ }
+
+ @Test
+ public void setSwitchUserUrlWhenNullThenThrowException() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("switchUserUrl cannot be empty and must be a valid redirect URL");
+ // when
+ switchUserWebFilter.setSwitchUserUrl(null);
+ // then
+ fail("Test should fail with exception");
+ }
+
+ @Test
+ public void setSwitchUserUrlWhenInvalidThenThrowException() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("switchUserUrl cannot be empty and must be a valid redirect URL");
+ // when
+ switchUserWebFilter.setSwitchUserUrl("wrongUrl");
+ // then
+ fail("Test should fail with exception");
+ }
+
+ @Test
+ public void setSwitchUserUrlWhenDefinedThenChangeDefaultValue() {
+ // given
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/login/impersonate"));
+
+ final ServerWebExchangeMatcher oldSwitchUserMatcher =
+ (ServerWebExchangeMatcher) ReflectionTestUtils.getField(switchUserWebFilter, "switchUserMatcher");
+
+ assertThat(oldSwitchUserMatcher.matches(exchange).block().isMatch()).isTrue();
+
+ // when
+ switchUserWebFilter.setSwitchUserUrl("/switch-url");
+
+ // then
+ final MockServerWebExchange newExchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/switch-url"));
+
+ final ServerWebExchangeMatcher newSwitchUserMatcher =
+ (ServerWebExchangeMatcher) ReflectionTestUtils.getField(switchUserWebFilter, "switchUserMatcher");
+
+ assertThat(newSwitchUserMatcher.matches(newExchange).block().isMatch()).isTrue();
+ }
+
+ @Test
+ public void setSwitchUserMatcherWhenNullThenThrowException() {
+ // given
+ exceptionRule.expect(IllegalArgumentException.class);
+ exceptionRule.expectMessage("switchUserMatcher cannot be null");
+ // when
+ switchUserWebFilter.setSwitchUserMatcher(null);
+ // then
+ fail("Test should fail with exception");
+ }
+
+ @Test
+ public void setSwitchUserMatcherWhenDefinedThenChangeDefaultValue() {
+ // given
+ final MockServerWebExchange exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.post("/login/impersonate"));
+
+ final ServerWebExchangeMatcher oldSwitchUserMatcher =
+ (ServerWebExchangeMatcher) ReflectionTestUtils.getField(switchUserWebFilter, "switchUserMatcher");
+
+ assertThat(oldSwitchUserMatcher.matches(exchange).block().isMatch()).isTrue();
+
+ final ServerWebExchangeMatcher newSwitchUserMatcher =
+ ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/switch-url");
+
+ // when
+ switchUserWebFilter.setSwitchUserMatcher(newSwitchUserMatcher);
+
+ // then
+
+ final ServerWebExchangeMatcher currentExitUserMatcher =
+ (ServerWebExchangeMatcher) ReflectionTestUtils.getField(switchUserWebFilter, "switchUserMatcher");
+
+ assertSame(newSwitchUserMatcher, currentExitUserMatcher);
+ }
+
+ private UserDetails switchUserDetails(String username, boolean enabled) {
+ final SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_SWITCH_TEST");
+ return new User(username, "NA", enabled,
+ true, true, true, Collections.singleton(authority));
+ }
+}