diff --git a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc index 9edd4492bdb..f7cd752328a 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc @@ -122,6 +122,12 @@ class OAuth2LoginSecurityConfig { If used, the application's base URL, such as `https://app.example.org`, replaces it at request time. ==== +[NOTE] +==== +By default, `OidcClientInitiatedLogoutSuccessHandler` redirects to the logout URL using a standard HTTP redirect with the `GET` method. +To perform the logout using a `POST` request, set the redirect strategy to `FormRedirectStrategy`, for example with `OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormRedirectStrategy())`. +==== + [[configure-provider-initiated-oidc-logout]] == OpenID Connect 1.0 Back-Channel Logout diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java index 86df3618f27..54c44930641 100644 --- a/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java +++ b/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java @@ -54,6 +54,11 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { hints.resources().registerResource(webauthnJavascript); } + ClassPathResource redirect = new ClassPathResource("org/springframework/security/form-redirect.js"); + if (redirect.exists()) { + hints.resources().registerResource(redirect); + } + } } diff --git a/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java b/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java index 7f96e6f0d98..a147e1b3019 100644 --- a/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java @@ -98,4 +98,21 @@ public static DefaultResourcesWebFilter css() { new MediaType("text", "css", StandardCharsets.UTF_8)); } + /** + * Create an instance of {@link DefaultResourcesWebFilter} serving Spring Security's + * form redirect javascript. + *

+ * The created {@link DefaultResourcesFilter} matches requests + * {@code HTTP GET /form-redirect.js}, and returns the default javascript at + * {@code org/springframework/security/form-redirect.js} with content-type + * {@code text/javascript;charset=UTF-8}. + * @return - + */ + public static DefaultResourcesWebFilter formRedirectJavascript() { + return new DefaultResourcesWebFilter( + new PathPatternParserServerWebExchangeMatcher("/form-redirect.js", HttpMethod.GET), + new ClassPathResource("org/springframework/security/form-redirect.js"), + new MediaType("text", "javascript", StandardCharsets.UTF_8)); + } + } diff --git a/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java b/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java new file mode 100644 index 00000000000..46dc09f5b47 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java @@ -0,0 +1,96 @@ +/* + * Copyright 2002-2023 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.ui; + +import java.io.IOException; +import java.util.List; +import java.util.Map.Entry; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.web.util.HtmlUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Redirect using an autosubmitting HTML form using the POST method. All query params + * provided in the URL are changed to inputs in the form so they are submitted as POST + * data instead of query string data. + */ +/* default */ class FormRedirectStrategy implements RedirectStrategy { + + private static final String REDIRECT_PAGE_TEMPLATE = """ + + + + + + + + Redirect + + + +

+
+ {{params}} + +
+
+ + + + """; + + private static final String HIDDEN_INPUT_TEMPLATE = """ + + """; + + @Override + public void sendRedirect(final HttpServletRequest request, final HttpServletResponse response, final String url) + throws IOException { + final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(url); + + final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder(); + // inputs + for (final Entry> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) { + final String name = entry.getKey(); + for (final String value : entry.getValue()) { + hiddenInputsHtmlBuilder.append(HtmlTemplates.fromTemplate(HIDDEN_INPUT_TEMPLATE) + .withValue("name", name) + .withValue("value", value) + .render()); + } + } + + final String html = HtmlTemplates.fromTemplate(REDIRECT_PAGE_TEMPLATE) + // clear the query string as we don't want that to be part of the form action + // URL + .withValue("action", uriComponentsBuilder.query(null).build().toUriString()) + .withRawHtml("params", hiddenInputsHtmlBuilder.toString()) + .withValue("contextPath", request.getContextPath()) + .render(); + response.setStatus(HttpStatus.OK.value()); + response.setContentType(MediaType.TEXT_HTML_VALUE); + response.getWriter().write(html); + response.getWriter().flush(); + } + +} diff --git a/web/src/main/resources/org/springframework/security/form-redirect.js b/web/src/main/resources/org/springframework/security/form-redirect.js new file mode 100644 index 00000000000..aecae36ee0a --- /dev/null +++ b/web/src/main/resources/org/springframework/security/form-redirect.js @@ -0,0 +1 @@ +document.getElementById("redirectForm").submit(); diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java index 180d10db48c..b781f7c3ab4 100644 --- a/web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java +++ b/web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java @@ -74,4 +74,10 @@ void webauthnJavascriptHasHints() { .forResource("org/springframework/security/spring-security-webauthn.js")).accepts(this.hints); } + @Test + void formRedirectJavascriptHasHints() { + assertThat(RuntimeHintsPredicates.resource().forResource("org/springframework/security/form-redirect.js")) + .accepts(this.hints); + } + } diff --git a/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesCssWebFilterTests.java similarity index 98% rename from web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java rename to web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesCssWebFilterTests.java index 9b0b5c64b3c..590fe8cb635 100644 --- a/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesCssWebFilterTests.java @@ -36,7 +36,7 @@ * @author Daniel Garnier-Moiroux * @since 6.4 */ -class DefaultResourcesWebFilterTests { +class DefaultResourcesCssWebFilterTests { private final WebHandler notFoundHandler = (exchange) -> { exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); diff --git a/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesFormRedirectJavascriptWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesFormRedirectJavascriptWebFilterTests.java new file mode 100644 index 00000000000..ad8961a4a5a --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesFormRedirectJavascriptWebFilterTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2024 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.ui; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.handler.DefaultWebFilterChain; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Craig Andrews + * @since 6.4 + */ +class DefaultResourcesFormRedirectJavascriptWebFilterTests { + + private final WebHandler notFoundHandler = (exchange) -> { + exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); + return Mono.empty(); + }; + + private final DefaultResourcesWebFilter filter = DefaultResourcesWebFilter.formRedirectJavascript(); + + @Test + void filterWhenPathMatchesThenRenders() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/form-redirect.js")); + WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter)); + + filterChain.filter(exchange).block(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(exchange.getResponse().getHeaders().getContentType()) + .isEqualTo(new MediaType("text", "javascript", StandardCharsets.UTF_8)); + assertThat(exchange.getResponse().getBodyAsString().block()).contains("document"); + } + + @Test + void filterWhenPathDoesNotMatchThenCallsThrough() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/does-not-match")); + WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter)); + + filterChain.filter(exchange).block(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void toStringPrintsPathAndResource() { + assertThat(this.filter.toString()).isEqualTo( + "DefaultResourcesWebFilter{matcher=PathMatcherServerWebExchangeMatcher{pattern='/form-redirect.js', method=GET}, resource='org/springframework/security/form-redirect.js'}"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java b/web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java new file mode 100644 index 00000000000..13105f0fedd --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-2023 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.ui; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import static org.assertj.core.api.Assertions.assertThat; + +public class FormRedirectStrategyTests { + + private FormRedirectStrategy formRedirectStrategy; + + private MockHttpServletRequest request; + + private MockHttpServletResponse response; + + @BeforeEach + public void beforeEach() { + this.formRedirectStrategy = new FormRedirectStrategy(); + final MockServletContext mockServletContext = new MockServletContext(); + mockServletContext.setContextPath("/contextPath"); + // the request URL doesn't matter + this.request = MockMvcRequestBuilders.get("https://localhost").buildRequest(mockServletContext); + this.response = new MockHttpServletResponse(); + } + + @Test + public void absoluteUrlNoParametersRedirect() throws IOException { + this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"https://example.com\""); + } + + @Test + public void rootRelativeUrlNoParametersRedirect() throws IOException { + this.formRedirectStrategy.sendRedirect(this.request, this.response, "/test"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"/test\""); + } + + @Test + public void relativeUrlNoParametersRedirect() throws IOException { + this.formRedirectStrategy.sendRedirect(this.request, this.response, "test"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"test\""); + } + + @Test + public void absoluteUrlWithFragmentRedirect() throws IOException { + this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path#fragment"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/path#fragment\""); + } + + @Test + public void absoluteUrlWithQueryParamsRedirect() throws IOException { + this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path?param1=one¶m2=two#fragment"); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); + assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/path#fragment\""); + assertThat(this.response.getContentAsString()).contains(""); + assertThat(this.response.getContentAsString()).contains(""); + } + +}