diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 847af8e364a..6b751ec7841 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -3033,7 +3033,8 @@ private LogoutSpec() { /** * Configures the logout handler. Default is - * {@code SecurityContextServerLogoutHandler} + * {@code SecurityContextServerLogoutHandler}. This clears any previous handlers + * configured. * @param logoutHandler * @return the {@link LogoutSpec} to configure */ @@ -3049,6 +3050,18 @@ private LogoutSpec addLogoutHandler(ServerLogoutHandler logoutHandler) { return this; } + /** + * Allows managing the list of {@link ServerLogoutHandler} instances. + * @param handlersConsumer {@link Consumer} for managing the list of handlers. + * @return the {@link LogoutSpec} to configure + * @since 7.0 + */ + public LogoutSpec logoutHandler(Consumer> handlersConsumer) { + Assert.notNull(handlersConsumer, "consumer cannot be null"); + handlersConsumer.accept(this.logoutHandlers); + return this; + } + /** * Configures what URL a POST to will trigger a log out. * @param logoutUrl the url to trigger a log out (i.e. "/signout" would mean a diff --git a/config/src/test/java/org/springframework/security/config/web/server/LogoutSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/LogoutSpecTests.java index c8bfc6dcfd4..99e68827ddf 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/LogoutSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/LogoutSpecTests.java @@ -16,18 +16,27 @@ package org.springframework.security.config.web.server; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.openqa.selenium.WebDriver; +import reactor.core.publisher.Mono; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.annotation.web.reactive.ServerHttpSecurityConfigurationBuilder; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.htmlunit.server.WebTestClientHtmlUnitDriverBuilder; import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.config.Customizer.withDefaults; @@ -210,6 +219,84 @@ public void logoutWhenCustomSecurityContextRepositoryThenLogsOut() { FormLoginTests.HomePage.to(driver, FormLoginTests.DefaultLoginPage.class).assertAt(); } + @Test + public void multipleLogoutHandlers() { + InMemorySecurityContextRepository repository = new InMemorySecurityContextRepository(); + MultiValueMap logoutData = new LinkedMultiValueMap<>(); + ServerLogoutHandler handler1 = (exchange, authentication) -> { + logoutData.add("handler-header", "value1"); + return Mono.empty(); + }; + ServerLogoutHandler handler2 = (exchange, authentication) -> { + logoutData.add("handler-header", "value2"); + return Mono.empty(); + }; + // @formatter:off + SecurityWebFilterChain securityWebFilter = this.http + .securityContextRepository(repository) + .authorizeExchange((authorize) -> authorize + .anyExchange().authenticated()) + .formLogin(withDefaults()) + .logout((logoutSpec) -> logoutSpec.logoutHandler((handlers) -> { + handlers.add(handler1); + handlers.add(0, handler2); + })) + .build(); + WebTestClient webTestClient = WebTestClientBuilder + .bindToWebFilters(securityWebFilter) + .build(); + WebDriver driver = WebTestClientHtmlUnitDriverBuilder + .webTestClientSetup(webTestClient) + .build(); + // @formatter:on + FormLoginTests.DefaultLoginPage loginPage = FormLoginTests.HomePage + .to(driver, FormLoginTests.DefaultLoginPage.class) + .assertAt(); + // @formatter:off + loginPage = loginPage.loginForm() + .username("user") + .password("invalid") + .submit(FormLoginTests.DefaultLoginPage.class) + .assertError(); + FormLoginTests.HomePage homePage = loginPage.loginForm() + .username("user") + .password("password") + .submit(FormLoginTests.HomePage.class); + // @formatter:on + homePage.assertAt(); + SecurityContext savedContext = repository.getSavedContext(); + assertThat(savedContext).isNotNull(); + assertThat(savedContext.getAuthentication()).isInstanceOf(UsernamePasswordAuthenticationToken.class); + + loginPage = FormLoginTests.DefaultLogoutPage.to(driver).assertAt().logout(); + loginPage.assertAt().assertLogout(); + assertThat(logoutData).hasSize(1); + assertThat(logoutData.get("handler-header")).containsExactly("value2", "value1"); + savedContext = repository.getSavedContext(); + assertThat(savedContext).isNull(); + } + + private static class InMemorySecurityContextRepository implements ServerSecurityContextRepository { + + @Nullable private SecurityContext savedContext; + + @Override + public Mono save(ServerWebExchange exchange, SecurityContext context) { + this.savedContext = context; + return Mono.empty(); + } + + @Override + public Mono load(ServerWebExchange exchange) { + return Mono.justOrEmpty(this.savedContext); + } + + @Nullable private SecurityContext getSavedContext() { + return this.savedContext; + } + + } + @RestController public static class HomeController {