diff --git a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java index 14e7e982b..7a5070010 100644 --- a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java @@ -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 securityConfigurers, ServerSecurityContextRepository securityContextRepository, - ReactiveExtensionClient client, - CryptoService cryptoService, HaloProperties haloProperties, ServerRequestCache serverRequestCache) { diff --git a/application/src/main/java/run/halo/app/security/HaloRedirectAuthenticationSuccessHandler.java b/application/src/main/java/run/halo/app/security/HaloRedirectAuthenticationSuccessHandler.java new file mode 100644 index 000000000..c5ed1fe36 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/HaloRedirectAuthenticationSuccessHandler.java @@ -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 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) + ); + } +} diff --git a/application/src/main/java/run/halo/app/security/HaloServerRequestCache.java b/application/src/main/java/run/halo/app/security/HaloServerRequestCache.java index 9b786d68c..67cb3eedc 100644 --- a/application/src/main/java/run/halo/app/security/HaloServerRequestCache.java +++ b/application/src/main/java/run/halo/app/security/HaloServerRequestCache.java @@ -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()); diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorizationExchangeConfigurers.java b/application/src/main/java/run/halo/app/security/authorization/AuthorizationExchangeConfigurers.java index d89391b02..413441cca 100644 --- a/application/src/main/java/run/halo/app/security/authorization/AuthorizationExchangeConfigurers.java +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorizationExchangeConfigurers.java @@ -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( - "/login/**", - "/challenges/**", - "/password-reset/**", - "/signup" - ).permitAll()); + 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()); } @Bean diff --git a/application/src/main/java/run/halo/app/security/switchuser/SwitchUserConfigurer.java b/application/src/main/java/run/halo/app/security/switchuser/SwitchUserConfigurer.java new file mode 100644 index 000000000..4f48f5a26 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/switchuser/SwitchUserConfigurer.java @@ -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); + } + +} diff --git a/application/src/test/java/run/halo/app/security/switchuser/SwitchUserConfigurerTest.java b/application/src/test/java/run/halo/app/security/switchuser/SwitchUserConfigurerTest.java new file mode 100644 index 000000000..71d9dee26 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/switchuser/SwitchUserConfigurerTest.java @@ -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(); + } + +}