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