From 0ccbd20f0aacb13d2c82f4b2d8910913eff9997b Mon Sep 17 00:00:00 2001 From: Max Batischev Date: Fri, 7 Feb 2025 15:22:10 +0300 Subject: [PATCH] Add Support ServerFormPostRedirectStrategy Closes gh-16542 Signed-off-by: Max Batischev --- .../pages/reactive/oauth2/login/logout.adoc | 6 + .../ServerFormPostRedirectStrategy.java | 124 ++++++++++++++++++ .../ServerFormPostRedirectStrategyTests.java | 117 +++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 web/src/main/java/org/springframework/security/web/server/ServerFormPostRedirectStrategy.java create mode 100644 web/src/test/java/org/springframework/security/web/server/ServerFormPostRedirectStrategyTests.java diff --git a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc index 15ce91c39c..4cfb7c8222 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc @@ -123,6 +123,12 @@ class OAuth2LoginSecurityConfig { If used, the application's base URL, such as `https://app.example.org`, replaces it at request time. ==== +[NOTE] +==== +By default, `OidcClientInitiatedServerLogoutSuccessHandler` 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 `ServerFormPostRedirectStrategy`, for example with `OidcClientInitiatedServerLogoutSuccessHandler.setRedirectStrategy(new ServerFormPostRedirectStrategy())`. +==== + [[configure-provider-initiated-oidc-logout]] == OpenID Connect 1.0 Back-Channel Logout diff --git a/web/src/main/java/org/springframework/security/web/server/ServerFormPostRedirectStrategy.java b/web/src/main/java/org/springframework/security/web/server/ServerFormPostRedirectStrategy.java new file mode 100644 index 0000000000..2836f9ca85 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/ServerFormPostRedirectStrategy.java @@ -0,0 +1,124 @@ +/* + * Copyright 2002-2025 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; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.HtmlUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Redirect using an auto-submitting 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. + * + * @author Max Batischev + * @since 6.5 + */ +public final class ServerFormPostRedirectStrategy implements ServerRedirectStrategy { + + private static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy"; + + private static final StringKeyGenerator DEFAULT_NONCE_GENERATOR = new Base64StringKeyGenerator( + Base64.getUrlEncoder().withoutPadding(), 96); + + private static final String REDIRECT_PAGE_TEMPLATE = """ + + + + + + + + Redirect + + +
+ {{params}} + +
+ + + + """; + + private static final String HIDDEN_INPUT_TEMPLATE = """ + + """; + + @Override + public Mono sendRedirect(ServerWebExchange exchange, URI location) { + String nonce = DEFAULT_NONCE_GENERATOR.generateKey(); + String policyDirective = "script-src 'nonce-%s'".formatted(nonce); + + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.OK); + response.getHeaders().setContentType(MediaType.TEXT_HTML); + response.getHeaders().add(CONTENT_SECURITY_POLICY_HEADER, policyDirective); + return response.writeWith(createBuffer(exchange, location, nonce)); + } + + private Mono createBuffer(ServerWebExchange exchange, URI location, String nonce) { + byte[] bytes = createPage(location, nonce); + DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); + return Mono.just(bufferFactory.wrap(bytes)); + } + + private byte[] createPage(URI location, String nonce) { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(location); + + StringBuilder hiddenInputsHtmlBuilder = new StringBuilder(); + for (final Map.Entry> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) { + final String name = entry.getKey(); + for (final String value : entry.getValue()) { + // @formatter:off + final String hiddenInput = HIDDEN_INPUT_TEMPLATE + .replace("{{name}}", HtmlUtils.htmlEscape(name)) + .replace("{{value}}", HtmlUtils.htmlEscape(value)); + // @formatter:on + hiddenInputsHtmlBuilder.append(hiddenInput.trim()); + } + } + // @formatter:off + return REDIRECT_PAGE_TEMPLATE + .replace("{{action}}", HtmlUtils.htmlEscape(uriComponentsBuilder.query(null).build().toUriString())) + .replace("{{params}}", hiddenInputsHtmlBuilder.toString()) + .replace("{{nonce}}", HtmlUtils.htmlEscape(nonce)) + .getBytes(StandardCharsets.UTF_8); + // @formatter:on + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/ServerFormPostRedirectStrategyTests.java b/web/src/test/java/org/springframework/security/web/server/ServerFormPostRedirectStrategyTests.java new file mode 100644 index 0000000000..67d65d2ab0 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/ServerFormPostRedirectStrategyTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2025 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; + +import java.net.URI; + +import org.assertj.core.api.ThrowingConsumer; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.mock.web.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ServerFormPostRedirectStrategy}. + * + * @author Max Batischev + */ +public class ServerFormPostRedirectStrategyTests { + + private static final String POLICY_DIRECTIVE_PATTERN = "script-src 'nonce-(.+)'"; + + private final ServerRedirectStrategy redirectStrategy = new ServerFormPostRedirectStrategy(); + + private final MockServerHttpRequest request = MockServerHttpRequest.get("https://localhost").build(); + + private final MockServerWebExchange webExchange = MockServerWebExchange.from(this.request); + + @Test + public void redirectWhetLocationAbsoluteUriIsPresentThenRedirect() { + this.redirectStrategy.sendRedirect(this.webExchange, URI.create("https://example.com")).block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + assertThat(response.getBodyAsString().block()).contains("action=\"https://example.com\""); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce()); + } + + @Test + public void redirectWhetLocationRootRelativeUriIsPresentThenRedirect() { + this.redirectStrategy.sendRedirect(this.webExchange, URI.create("/test")).block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + assertThat(response.getBodyAsString().block()).contains("action=\"/test\""); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce()); + } + + @Test + public void redirectWhetLocationRelativeUriIsPresentThenRedirect() { + this.redirectStrategy.sendRedirect(this.webExchange, URI.create("test")).block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + assertThat(response.getBodyAsString().block()).contains("action=\"test\""); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce()); + } + + @Test + public void redirectWhenLocationAbsoluteUriWithFragmentIsPresentThenRedirect() { + this.redirectStrategy.sendRedirect(this.webExchange, URI.create("https://example.com/path#fragment")).block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + assertThat(response.getBodyAsString().block()).contains("action=\"https://example.com/path#fragment\""); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(this.webExchange.getResponse()).satisfies(hasScriptSrcNonce()); + } + + @Test + public void redirectWhenLocationAbsoluteUilWithQueryParamsIsPresentThenRedirect() { + this.redirectStrategy + .sendRedirect(this.webExchange, URI.create("https://example.com/path?param1=one¶m2=two#fragment")) + .block(); + + MockServerHttpResponse response = this.webExchange.getResponse(); + String content = response.getBodyAsString().block(); + assertThat(this.webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(this.webExchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(content).contains("action=\"https://example.com/path#fragment\""); + assertThat(content).contains(""); + assertThat(content).contains(""); + } + + private ThrowingConsumer hasScriptSrcNonce() { + return (response) -> { + final String policyDirective = response.getHeaders().get("Content-Security-Policy").get(0); + assertThat(policyDirective).isNotEmpty(); + assertThat(policyDirective).matches(POLICY_DIRECTIVE_PATTERN); + + final String nonce = policyDirective.replaceFirst(POLICY_DIRECTIVE_PATTERN, "$1"); + assertThat(response.getBodyAsString().block()).contains("