mirror of https://github.com/halo-dev/halo
Support impersonating other users for super admin (#7351)
#### What type of PR is this? /kind feature /area core /milestone 2.20.x #### What this PR does / why we need it: This PR adds support for impersonating other users for super admin. 1. Login as super admin 2. Request `POST /login/impersonate?username=xxx` and the current user should be xxx 3. Request `POST /logout/impersonate` and the current user should be super admin #### Does this PR introduce a user-facing change? ```release-note None ```pull/7353/head^2
parent
482436b2d0
commit
e0b9c50d71
|
@ -25,7 +25,6 @@ import org.springframework.session.MapSession;
|
||||||
import org.springframework.session.config.annotation.web.server.EnableSpringWebSession;
|
import org.springframework.session.config.annotation.web.server.EnableSpringWebSession;
|
||||||
import run.halo.app.core.user.service.RoleService;
|
import run.halo.app.core.user.service.RoleService;
|
||||||
import run.halo.app.core.user.service.UserService;
|
import run.halo.app.core.user.service.UserService;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
|
||||||
import run.halo.app.infra.AnonymousUserConst;
|
import run.halo.app.infra.AnonymousUserConst;
|
||||||
import run.halo.app.infra.properties.HaloProperties;
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
import run.halo.app.security.DefaultUserDetailService;
|
import run.halo.app.security.DefaultUserDetailService;
|
||||||
|
@ -51,11 +50,8 @@ public class WebServerSecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
SecurityWebFilterChain filterChain(ServerHttpSecurity http,
|
SecurityWebFilterChain filterChain(ServerHttpSecurity http,
|
||||||
RoleService roleService,
|
|
||||||
ObjectProvider<SecurityConfigurer> securityConfigurers,
|
ObjectProvider<SecurityConfigurer> securityConfigurers,
|
||||||
ServerSecurityContextRepository securityContextRepository,
|
ServerSecurityContextRepository securityContextRepository,
|
||||||
ReactiveExtensionClient client,
|
|
||||||
CryptoService cryptoService,
|
|
||||||
HaloProperties haloProperties,
|
HaloProperties haloProperties,
|
||||||
ServerRequestCache serverRequestCache) {
|
ServerRequestCache serverRequestCache) {
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
package run.halo.app.security;
|
||||||
|
|
||||||
|
import static run.halo.app.security.HaloServerRequestCache.uriInApplication;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.web.server.DefaultServerRedirectStrategy;
|
||||||
|
import org.springframework.security.web.server.ServerRedirectStrategy;
|
||||||
|
import org.springframework.security.web.server.WebFilterExchange;
|
||||||
|
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is responsible for handling the redirection after a successful authentication.
|
||||||
|
* It checks if a valid 'redirect_uri' query parameter is present in the request. If it is,
|
||||||
|
* the user is redirected to the specified URI. Otherwise, the user is redirected to a default
|
||||||
|
* location.
|
||||||
|
*
|
||||||
|
* @author johnniang
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class HaloRedirectAuthenticationSuccessHandler
|
||||||
|
implements ServerAuthenticationSuccessHandler {
|
||||||
|
|
||||||
|
private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
|
||||||
|
|
||||||
|
private final URI location;
|
||||||
|
|
||||||
|
public HaloRedirectAuthenticationSuccessHandler(String location) {
|
||||||
|
this.location = URI.create(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
|
||||||
|
Authentication authentication) {
|
||||||
|
var request = webFilterExchange.getExchange().getRequest();
|
||||||
|
var redirectUriQuery = request.getQueryParams()
|
||||||
|
.getFirst("redirect_uri");
|
||||||
|
if (redirectUriQuery == null || redirectUriQuery.isBlank()) {
|
||||||
|
return redirectStrategy.sendRedirect(webFilterExchange.getExchange(), location);
|
||||||
|
}
|
||||||
|
var redirectUri = uriInApplication(request, URI.create(redirectUriQuery));
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.debug(
|
||||||
|
"Redirecting to: {} after switching to {}",
|
||||||
|
redirectUri, authentication.getName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return redirectStrategy.sendRedirect(
|
||||||
|
webFilterExchange.getExchange(), URI.create(redirectUri)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -87,11 +87,11 @@ public class HaloServerRequestCache extends WebSessionServerRequestCache {
|
||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String uriInApplication(ServerHttpRequest request, URI uri) {
|
public static String uriInApplication(ServerHttpRequest request, URI uri) {
|
||||||
return uriInApplication(request, uri, true);
|
return uriInApplication(request, uri, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String uriInApplication(
|
public static String uriInApplication(
|
||||||
ServerHttpRequest request, URI uri, boolean appendFragment
|
ServerHttpRequest request, URI uri, boolean appendFragment
|
||||||
) {
|
) {
|
||||||
var path = RequestPath.parse(uri, request.getPath().contextPath().value());
|
var path = RequestPath.parse(uri, request.getPath().contextPath().value());
|
||||||
|
|
|
@ -9,6 +9,7 @@ import org.springframework.security.authentication.AuthenticationTrustResolver;
|
||||||
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
|
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
|
||||||
import org.springframework.security.authorization.AuthorizationDecision;
|
import org.springframework.security.authorization.AuthorizationDecision;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.web.server.authentication.SwitchUserWebFilter;
|
||||||
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
|
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
|
||||||
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
|
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
|
||||||
import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
|
import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
|
||||||
|
@ -55,12 +56,18 @@ class AuthorizationExchangeConfigurers {
|
||||||
@Bean
|
@Bean
|
||||||
@Order(200)
|
@Order(200)
|
||||||
SecurityConfigurer preAuthenticationAuthorizationConfigurer() {
|
SecurityConfigurer preAuthenticationAuthorizationConfigurer() {
|
||||||
return http -> http.authorizeExchange(spec -> spec.pathMatchers(
|
return http -> http.authorizeExchange(spec -> spec
|
||||||
"/login/**",
|
.pathMatchers("/login/impersonate")
|
||||||
"/challenges/**",
|
.hasRole(AuthorityUtils.SUPER_ROLE_NAME)
|
||||||
"/password-reset/**",
|
.pathMatchers("/logout/impersonate")
|
||||||
"/signup"
|
.hasAuthority(SwitchUserWebFilter.ROLE_PREVIOUS_ADMINISTRATOR)
|
||||||
).permitAll());
|
.pathMatchers(
|
||||||
|
"/login/**",
|
||||||
|
"/challenges/**",
|
||||||
|
"/password-reset/**",
|
||||||
|
"/signup"
|
||||||
|
)
|
||||||
|
.permitAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
package run.halo.app.security.switchuser;
|
||||||
|
|
||||||
|
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||||
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
|
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||||
|
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler;
|
||||||
|
import org.springframework.security.web.server.authentication.SwitchUserWebFilter;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import run.halo.app.security.HaloRedirectAuthenticationSuccessHandler;
|
||||||
|
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch user configurer.
|
||||||
|
*
|
||||||
|
* @author johnniang
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class SwitchUserConfigurer implements SecurityConfigurer {
|
||||||
|
|
||||||
|
private final ReactiveUserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
SwitchUserConfigurer(ReactiveUserDetailsService userDetailsService) {
|
||||||
|
this.userDetailsService = userDetailsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(ServerHttpSecurity http) {
|
||||||
|
var successHandler = new HaloRedirectAuthenticationSuccessHandler("/console");
|
||||||
|
var failureHandler =
|
||||||
|
new RedirectServerAuthenticationFailureHandler("/login?error=impersonate");
|
||||||
|
var filter = new SwitchUserWebFilter(userDetailsService, successHandler, failureHandler);
|
||||||
|
http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHORIZATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
package run.halo.app.security.switchuser;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
|
||||||
|
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockUser;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.security.authorization.AuthorityUtils;
|
||||||
|
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureWebTestClient
|
||||||
|
class SwitchUserConfigurerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
WebTestClient webClient;
|
||||||
|
|
||||||
|
@MockitoSpyBean
|
||||||
|
ReactiveUserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSwitchWithSuperRole() {
|
||||||
|
when(userDetailsService.findByUsername("faker"))
|
||||||
|
.thenReturn(Mono.fromSupplier(() -> User.withUsername("faker")
|
||||||
|
.password("password")
|
||||||
|
.roles("user")
|
||||||
|
.build()));
|
||||||
|
var result = webClient.mutateWith(csrf())
|
||||||
|
.mutateWith(mockUser("admin").roles(AuthorityUtils.SUPER_ROLE_NAME))
|
||||||
|
.post()
|
||||||
|
.uri("/login/impersonate?username={username}&redirect_uri={redirect_uri}",
|
||||||
|
"faker", "/fake-success"
|
||||||
|
)
|
||||||
|
.exchange()
|
||||||
|
.expectStatus().isFound()
|
||||||
|
.expectHeader().location("/fake-success")
|
||||||
|
.expectCookie().exists("SESSION")
|
||||||
|
.expectBody().returnResult();
|
||||||
|
var session = result.getResponseCookies().getFirst("SESSION");
|
||||||
|
assertNotNull(session);
|
||||||
|
|
||||||
|
webClient.mutateWith(csrf())
|
||||||
|
.post().uri("/logout/impersonate?redirect_uri={redirect_uri}", "/fake-logout-success")
|
||||||
|
.cookie(session.getName(), session.getValue())
|
||||||
|
.exchange()
|
||||||
|
.expectStatus().isFound()
|
||||||
|
.expectHeader().location("/fake-logout-success");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin", roles = "non-super-role")
|
||||||
|
void shouldNotSwitchWithoutSuperRole() {
|
||||||
|
webClient.mutateWith(csrf())
|
||||||
|
.post()
|
||||||
|
.uri("/login/impersonate?username={username}&redirect_uri={redirect_uri}",
|
||||||
|
"faker", "/fake-success"
|
||||||
|
)
|
||||||
|
.exchange()
|
||||||
|
.expectStatus().isForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue