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
John Niang 2025-04-20 16:04:46 +08:00 committed by GitHub
parent 482436b2d0
commit e0b9c50d71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 176 additions and 12 deletions

View File

@ -25,7 +25,6 @@ import org.springframework.session.MapSession;
import org.springframework.session.config.annotation.web.server.EnableSpringWebSession;
import run.halo.app.core.user.service.RoleService;
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.properties.HaloProperties;
import run.halo.app.security.DefaultUserDetailService;
@ -51,11 +50,8 @@ public class WebServerSecurityConfig {
@Bean
SecurityWebFilterChain filterChain(ServerHttpSecurity http,
RoleService roleService,
ObjectProvider<SecurityConfigurer> securityConfigurers,
ServerSecurityContextRepository securityContextRepository,
ReactiveExtensionClient client,
CryptoService cryptoService,
HaloProperties haloProperties,
ServerRequestCache serverRequestCache) {

View File

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

View File

@ -87,11 +87,11 @@ public class HaloServerRequestCache extends WebSessionServerRequestCache {
.then();
}
private static String uriInApplication(ServerHttpRequest request, URI uri) {
public static String uriInApplication(ServerHttpRequest request, URI uri) {
return uriInApplication(request, uri, true);
}
private static String uriInApplication(
public static String uriInApplication(
ServerHttpRequest request, URI uri, boolean appendFragment
) {
var path = RequestPath.parse(uri, request.getPath().contextPath().value());

View File

@ -9,6 +9,7 @@ import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.authorization.AuthorizationDecision;
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.MediaTypeServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
@ -55,12 +56,18 @@ class AuthorizationExchangeConfigurers {
@Bean
@Order(200)
SecurityConfigurer preAuthenticationAuthorizationConfigurer() {
return http -> http.authorizeExchange(spec -> spec.pathMatchers(
return http -> http.authorizeExchange(spec -> spec
.pathMatchers("/login/impersonate")
.hasRole(AuthorityUtils.SUPER_ROLE_NAME)
.pathMatchers("/logout/impersonate")
.hasAuthority(SwitchUserWebFilter.ROLE_PREVIOUS_ADMINISTRATOR)
.pathMatchers(
"/login/**",
"/challenges/**",
"/password-reset/**",
"/signup"
).permitAll());
)
.permitAll());
}
@Bean

View File

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

View File

@ -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();
}
}