From 9d4d9065b48508c9e115f2f894632120787fd1df Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:05:08 -0600 Subject: [PATCH 1/6] Cap Deflate Size Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../security/saml2/internal/Saml2Utils.java | 26 +++++++++++- .../service/authentication/Saml2Utils.java | 26 +++++++++++- .../authentication/logout/Saml2Utils.java | 26 +++++++++++- .../provider/service/metadata/Saml2Utils.java | 26 +++++++++++- .../service/registration/Saml2Utils.java | 26 +++++++++++- .../provider/service/web/Saml2Utils.java | 26 +++++++++++- .../web/authentication/Saml2Utils.java | 26 +++++++++++- .../web/authentication/logout/Saml2Utils.java | 26 +++++++++++- .../saml2/internal/Saml2UtilsTests.java | 40 +++++++++++++++++++ 9 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/internal/Saml2UtilsTests.java diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/internal/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/internal/Saml2Utils.java index 91dae712f4..860dee0f97 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/internal/Saml2Utils.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/internal/Saml2Utils.java @@ -18,6 +18,7 @@ package org.springframework.security.saml2.internal; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; @@ -64,7 +65,7 @@ final class Saml2Utils { static String samlInflate(byte[] b) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); - InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + InflaterOutputStream iout = new InflaterOutputStream(new CappedOutputStream(out), new Inflater(true)); iout.write(b); iout.finish(); return new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -193,4 +194,27 @@ final class Saml2Utils { } + static class CappedOutputStream extends OutputStream { + + private static final long MAX_SIZE = 1024 * 1024; + + private final OutputStream delegate; + + private int size; + + CappedOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + if (this.size >= MAX_SIZE) { + throw new IOException("SAML payload exceeded maximum size of " + MAX_SIZE); + } + this.delegate.write(b); + this.size++; + } + + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Utils.java index 50d0d13967..162c56f569 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Utils.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Utils.java @@ -18,6 +18,7 @@ package org.springframework.security.saml2.provider.service.authentication; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; @@ -64,7 +65,7 @@ final class Saml2Utils { static String samlInflate(byte[] b) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); - InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + InflaterOutputStream iout = new InflaterOutputStream(new CappedOutputStream(out), new Inflater(true)); iout.write(b); iout.finish(); return new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -193,4 +194,27 @@ final class Saml2Utils { } + static class CappedOutputStream extends OutputStream { + + private static final long MAX_SIZE = 1024 * 1024; + + private final OutputStream delegate; + + private int size; + + CappedOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + if (this.size >= MAX_SIZE) { + throw new IOException("SAML payload exceeded maximum size of " + MAX_SIZE); + } + this.delegate.write(b); + this.size++; + } + + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java index 75129fbeaf..c65295d033 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java @@ -18,6 +18,7 @@ package org.springframework.security.saml2.provider.service.authentication.logou import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; @@ -64,7 +65,7 @@ final class Saml2Utils { static String samlInflate(byte[] b) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); - InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + InflaterOutputStream iout = new InflaterOutputStream(new CappedOutputStream(out), new Inflater(true)); iout.write(b); iout.finish(); return new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -193,4 +194,27 @@ final class Saml2Utils { } + static class CappedOutputStream extends OutputStream { + + private static final long MAX_SIZE = 1024 * 1024; + + private final OutputStream delegate; + + private int size; + + CappedOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + if (this.size >= MAX_SIZE) { + throw new IOException("SAML payload exceeded maximum size of " + MAX_SIZE); + } + this.delegate.write(b); + this.size++; + } + + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/Saml2Utils.java index 0d1c1d33b6..0f873de8ed 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/Saml2Utils.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/Saml2Utils.java @@ -18,6 +18,7 @@ package org.springframework.security.saml2.provider.service.metadata; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; @@ -64,7 +65,7 @@ final class Saml2Utils { static String samlInflate(byte[] b) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); - InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + InflaterOutputStream iout = new InflaterOutputStream(new CappedOutputStream(out), new Inflater(true)); iout.write(b); iout.finish(); return new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -193,4 +194,27 @@ final class Saml2Utils { } + static class CappedOutputStream extends OutputStream { + + private static final long MAX_SIZE = 1024 * 1024; + + private final OutputStream delegate; + + private int size; + + CappedOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + if (this.size >= MAX_SIZE) { + throw new IOException("SAML payload exceeded maximum size of " + MAX_SIZE); + } + this.delegate.write(b); + this.size++; + } + + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/Saml2Utils.java index 6f8818605f..2c72a4df7b 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/Saml2Utils.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/Saml2Utils.java @@ -18,6 +18,7 @@ package org.springframework.security.saml2.provider.service.registration; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; @@ -64,7 +65,7 @@ final class Saml2Utils { static String samlInflate(byte[] b) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); - InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + InflaterOutputStream iout = new InflaterOutputStream(new CappedOutputStream(out), new Inflater(true)); iout.write(b); iout.finish(); return new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -193,4 +194,27 @@ final class Saml2Utils { } + static class CappedOutputStream extends OutputStream { + + private static final long MAX_SIZE = 1024 * 1024; + + private final OutputStream delegate; + + private int size; + + CappedOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + if (this.size >= MAX_SIZE) { + throw new IOException("SAML payload exceeded maximum size of " + MAX_SIZE); + } + this.delegate.write(b); + this.size++; + } + + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2Utils.java index e923f1eab8..86bcb03cf9 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2Utils.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2Utils.java @@ -18,6 +18,7 @@ package org.springframework.security.saml2.provider.service.web; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; @@ -64,7 +65,7 @@ final class Saml2Utils { static String samlInflate(byte[] b) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); - InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + InflaterOutputStream iout = new InflaterOutputStream(new CappedOutputStream(out), new Inflater(true)); iout.write(b); iout.finish(); return new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -193,4 +194,27 @@ final class Saml2Utils { } + static class CappedOutputStream extends OutputStream { + + private static final long MAX_SIZE = 1024 * 1024; + + private final OutputStream delegate; + + private int size; + + CappedOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + if (this.size >= MAX_SIZE) { + throw new IOException("SAML payload exceeded maximum size of " + MAX_SIZE); + } + this.delegate.write(b); + this.size++; + } + + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/Saml2Utils.java index 70b9f47528..4c5f52faf9 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/Saml2Utils.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/Saml2Utils.java @@ -18,6 +18,7 @@ package org.springframework.security.saml2.provider.service.web.authentication; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; @@ -64,7 +65,7 @@ final class Saml2Utils { static String samlInflate(byte[] b) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); - InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + InflaterOutputStream iout = new InflaterOutputStream(new CappedOutputStream(out), new Inflater(true)); iout.write(b); iout.finish(); return new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -193,4 +194,27 @@ final class Saml2Utils { } + static class CappedOutputStream extends OutputStream { + + private static final long MAX_SIZE = 1024 * 1024; + + private final OutputStream delegate; + + private int size; + + CappedOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + if (this.size >= MAX_SIZE) { + throw new IOException("SAML payload exceeded maximum size of " + MAX_SIZE); + } + this.delegate.write(b); + this.size++; + } + + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java index 34de005d98..9f285a8f81 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java @@ -18,6 +18,7 @@ package org.springframework.security.saml2.provider.service.web.authentication.l import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; @@ -64,7 +65,7 @@ final class Saml2Utils { static String samlInflate(byte[] b) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); - InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + InflaterOutputStream iout = new InflaterOutputStream(new CappedOutputStream(out), new Inflater(true)); iout.write(b); iout.finish(); return new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -193,4 +194,27 @@ final class Saml2Utils { } + static class CappedOutputStream extends OutputStream { + + private static final long MAX_SIZE = 1024 * 1024; + + private final OutputStream delegate; + + private int size; + + CappedOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + if (this.size >= MAX_SIZE) { + throw new IOException("SAML payload exceeded maximum size of " + MAX_SIZE); + } + this.delegate.write(b); + this.size++; + } + + } + } diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/internal/Saml2UtilsTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/internal/Saml2UtilsTests.java new file mode 100644 index 0000000000..dc7b7e0603 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/internal/Saml2UtilsTests.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026-present 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.saml2.internal; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.saml2.Saml2Exception; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class Saml2UtilsTests { + + @Test + void testSaml2InflateWhenLargePayloadThenErrors() { + byte[] b = new byte[1024 * 1024 + 1]; + for (int i = 0; i < b.length; i++) { + b[i] = 56; + } + byte[] deflated = Saml2Utils.samlDeflate(new String(b, StandardCharsets.UTF_8)); + assertThatExceptionOfType(Saml2Exception.class).isThrownBy(() -> Saml2Utils.samlInflate(deflated)) + .withStackTraceContaining("SAML payload exceeded maximum size"); + } + +} From a14c9d66b15946d2040a3681b55fe29ac145c5c9 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 6 May 2026 15:01:14 -0600 Subject: [PATCH 2/6] Favor Relative URIs in CookieRequestCache Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../web/savedrequest/CookieRequestCache.java | 34 ++++++----- .../CookieServerRequestCache.java | 16 ++++-- .../savedrequest/CookieRequestCacheTests.java | 57 +++++++++++++++---- .../RequestCacheAwareFilterTests.java | 9 ++- .../CookieServerRequestCacheTests.java | 20 +++++++ 5 files changed, 100 insertions(+), 36 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/savedrequest/CookieRequestCache.java b/web/src/main/java/org/springframework/security/web/savedrequest/CookieRequestCache.java index c3cea8fca6..da7ec0efcb 100644 --- a/web/src/main/java/org/springframework/security/web/savedrequest/CookieRequestCache.java +++ b/web/src/main/java/org/springframework/security/web/savedrequest/CookieRequestCache.java @@ -16,6 +16,7 @@ package org.springframework.security.web.savedrequest; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Collections; import java.util.function.Consumer; @@ -61,7 +62,7 @@ public class CookieRequestCache implements RequestCache { this.logger.debug("Request not saved as configured RequestMatcher did not match"); return; } - String redirectUrl = UrlUtils.buildFullRequestUrl(request); + String redirectUrl = buildRelativeRequestUrl(request); Cookie savedCookie = new Cookie(COOKIE_NAME, encodeCookie(redirectUrl)); savedCookie.setMaxAge(COOKIE_MAX_AGE); savedCookie.setSecure(request.isSecure()); @@ -82,10 +83,14 @@ public class CookieRequestCache implements RequestCache { return null; } UriComponents uriComponents = UriComponentsBuilder.fromUriString(originalURI).build(); + if (!isRelativePath(originalURI)) { + this.logger.debug("Did not use saved request since cookie did not contain a relative path"); + return null; + } DefaultSavedRequest.Builder builder = new DefaultSavedRequest.Builder(); - int port = getPort(uriComponents); - return builder.setScheme(uriComponents.getScheme()) - .setServerName(uriComponents.getHost()) + int port = request.getServerPort(); + return builder.setScheme(request.getScheme()) + .setServerName(request.getServerName()) .setRequestURI(uriComponents.getPath()) .setQueryString(uriComponents.getQuery()) .setServerPort(port) @@ -95,15 +100,8 @@ public class CookieRequestCache implements RequestCache { .build(); } - private int getPort(UriComponents uriComponents) { - int port = uriComponents.getPort(); - if (port != -1) { - return port; - } - if ("https".equalsIgnoreCase(uriComponents.getScheme())) { - return 443; - } - return 80; + private boolean isRelativePath(String uri) { + return uri.startsWith("/") && !uri.startsWith("//"); } @Override @@ -127,13 +125,19 @@ public class CookieRequestCache implements RequestCache { response.addCookie(removeSavedRequestCookie); } + private static String buildRelativeRequestUrl(HttpServletRequest request) { + String uri = request.getRequestURI(); + String query = request.getQueryString(); + return (query != null) ? uri + "?" + query : uri; + } + private static String encodeCookie(String cookieValue) { - return Base64.getEncoder().encodeToString(cookieValue.getBytes()); + return Base64.getEncoder().encodeToString(cookieValue.getBytes(StandardCharsets.UTF_8)); } private String decodeCookie(String encodedCookieValue) { try { - return new String(Base64.getDecoder().decode(encodedCookieValue.getBytes())); + return new String(Base64.getDecoder().decode(encodedCookieValue.getBytes(StandardCharsets.UTF_8))); } catch (IllegalArgumentException ex) { this.logger.debug("Failed decode cookie value " + encodedCookieValue); diff --git a/web/src/main/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCache.java b/web/src/main/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCache.java index 87f0c4a4e4..ff0a459ee5 100644 --- a/web/src/main/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCache.java +++ b/web/src/main/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCache.java @@ -17,6 +17,7 @@ package org.springframework.security.web.server.savedrequest; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Base64; import java.util.Collections; @@ -96,8 +97,13 @@ public class CookieServerRequestCache implements ServerRequestCache { return Mono.justOrEmpty(cookieMap.getFirst(REDIRECT_URI_COOKIE_NAME)) .map(HttpCookie::getValue) .map(CookieServerRequestCache::decodeCookie) - .onErrorResume(IllegalArgumentException.class, (ex) -> Mono.empty()) - .map(URI::create); + .flatMap((decoded) -> isRelativePath(decoded) ? Mono.just(decoded) : Mono.empty()) + .map(URI::create) + .onErrorResume(IllegalArgumentException.class, (ex) -> Mono.empty()); + } + + private boolean isRelativePath(String uri) { + return uri.startsWith("/") && !uri.startsWith("//"); } @Override @@ -141,11 +147,13 @@ public class CookieServerRequestCache implements ServerRequestCache { } private static String encodeCookie(String cookieValue) { - return new String(Base64.getEncoder().encode(cookieValue.getBytes())); + return new String(Base64.getEncoder().encode(cookieValue.getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); } private static String decodeCookie(String encodedCookieValue) { - return new String(Base64.getDecoder().decode(encodedCookieValue.getBytes())); + return new String(Base64.getDecoder().decode(encodedCookieValue.getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); } private static ServerWebExchangeMatcher createDefaultRequestMatcher() { diff --git a/web/src/test/java/org/springframework/security/web/savedrequest/CookieRequestCacheTests.java b/web/src/test/java/org/springframework/security/web/savedrequest/CookieRequestCacheTests.java index 8862e5ca94..2ea66283af 100644 --- a/web/src/test/java/org/springframework/security/web/savedrequest/CookieRequestCacheTests.java +++ b/web/src/test/java/org/springframework/security/web/savedrequest/CookieRequestCacheTests.java @@ -54,7 +54,7 @@ public class CookieRequestCacheTests { Cookie savedCookie = response.getCookie(DEFAULT_COOKIE_NAME); assertThat(savedCookie).isNotNull(); String redirectUrl = decodeCookie(savedCookie.getValue()); - assertThat(redirectUrl).isEqualTo("https://abc.com/destination?param1=a¶m2=b¶m3=1122"); + assertThat(redirectUrl).isEqualTo("/destination?param1=a¶m2=b¶m3=1122"); assertThat(savedCookie.getMaxAge()).isEqualTo(-1); assertThat(savedCookie.getPath()).isEqualTo("/"); assertThat(savedCookie.isHttpOnly()).isTrue(); @@ -101,18 +101,53 @@ public class CookieRequestCacheTests { public void getRequestWhenRequestContainsSavedRequestCookieThenReturnsSaveRequest() { CookieRequestCache cookieRequestCache = new CookieRequestCache(); MockHttpServletRequest request = new MockHttpServletRequest(); - String redirectUrl = "https://abc.com/destination?param1=a¶m2=b¶m3=1122"; - request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl))); + request.setScheme("https"); + request.setServerName("abc.com"); + request.setServerPort(443); + request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination?param1=a¶m2=b¶m3=1122"))); SavedRequest savedRequest = cookieRequestCache.getRequest(request, new MockHttpServletResponse()); assertThat(savedRequest).isNotNull(); - assertThat(savedRequest.getRedirectUrl()).isEqualTo(redirectUrl); + assertThat(savedRequest.getRedirectUrl()) + .isEqualTo("https://abc.com/destination?param1=a¶m2=b¶m3=1122"); + } + + @Test + public void getRequestWhenRelativePathInCookieThenRedirectUrlUsesCurrentRequestOrigin() { + CookieRequestCache cookieRequestCache = new CookieRequestCache(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme("https"); + request.setServerName("myapp.com"); + request.setServerPort(443); + request.setSecure(true); + request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/secret?token=abc"))); + SavedRequest savedRequest = cookieRequestCache.getRequest(request, new MockHttpServletResponse()); + assertThat(savedRequest).isNotNull(); + assertThat(savedRequest.getRedirectUrl()).isEqualTo("https://myapp.com/secret?token=abc"); + } + + @Test + public void getRequestWhenAbsoluteUrlInCookieThenReturnsNull() { + CookieRequestCache cookieRequestCache = new CookieRequestCache(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("https://evil.com/phishing"))); + SavedRequest savedRequest = cookieRequestCache.getRequest(request, new MockHttpServletResponse()); + assertThat(savedRequest).isNull(); + } + + @Test + public void getRequestWhenProtocolRelativeUrlInCookieThenReturnsNull() { + CookieRequestCache cookieRequestCache = new CookieRequestCache(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("//evil.com/phishing"))); + SavedRequest savedRequest = cookieRequestCache.getRequest(request, new MockHttpServletResponse()); + assertThat(savedRequest).isNull(); } @Test public void getRequestWhenRequestContainsSavedRequestCookieThenSavedRequestContainsRequestParameters() { CookieRequestCache cookieRequestCache = new CookieRequestCache(); MockHttpServletRequest request = new MockHttpServletRequest(); - request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("https://abc.com/destination"))); + request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination"))); request.setParameter("single", "first"); request.addParameter("multi", "second"); request.addParameter("multi", "third"); @@ -143,8 +178,7 @@ public class CookieRequestCacheTests { request.setServerName("abc.com"); request.setRequestURI("/destination"); request.setQueryString("param1=a¶m2=b¶m3=1122"); - String redirectUrl = "https://abc.com/destination?param1=a¶m2=b¶m3=1122"; - request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl))); + request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination?param1=a¶m2=b¶m3=1122"))); MockHttpServletResponse response = new MockHttpServletResponse(); cookieRequestCache.getMatchingRequest(request, response); Cookie expiredCookie = response.getCookie(DEFAULT_COOKIE_NAME); @@ -162,8 +196,7 @@ public class CookieRequestCacheTests { request.setScheme("https"); request.setServerName("abc.com"); request.setRequestURI("/destination"); - String redirectUrl = "https://abc.com/api"; - request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl))); + request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/api"))); MockHttpServletResponse response = new MockHttpServletResponse(); final HttpServletRequest matchingRequest = cookieRequestCache.getMatchingRequest(request, response); assertThat(matchingRequest).isNull(); @@ -182,8 +215,8 @@ public class CookieRequestCacheTests { request.setRequestURI("/destination"); request.setQueryString("goto=https%3A%2F%2Fstart.spring.io"); request.setParameter("goto", "https://start.spring.io"); - String redirectUrl = "https://abc.com/destination?goto=https%3A%2F%2Fstart.spring.io"; - request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl))); + request.setCookies( + new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination?goto=https%3A%2F%2Fstart.spring.io"))); MockHttpServletResponse response = new MockHttpServletResponse(); final HttpServletRequest matchingRequest = cookieRequestCache.getMatchingRequest(request, response); assertThat(matchingRequest).isNotNull(); @@ -212,7 +245,7 @@ public class CookieRequestCacheTests { request.setServerName("example.com"); request.setRequestURI("/destination"); request.setPreferredLocales(Arrays.asList(Locale.FRENCH, Locale.GERMANY)); - String redirectUrl = "https://example.com/destination"; + String redirectUrl = "/destination"; request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl))); MockHttpServletResponse response = new MockHttpServletResponse(); HttpServletRequest matchingRequest = cookieRequestCache.getMatchingRequest(request, response); diff --git a/web/src/test/java/org/springframework/security/web/savedrequest/RequestCacheAwareFilterTests.java b/web/src/test/java/org/springframework/security/web/savedrequest/RequestCacheAwareFilterTests.java index a5416bb847..8f88af7a29 100644 --- a/web/src/test/java/org/springframework/security/web/savedrequest/RequestCacheAwareFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/savedrequest/RequestCacheAwareFilterTests.java @@ -16,8 +16,6 @@ package org.springframework.security.web.savedrequest; -import java.util.Base64; - import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.Test; @@ -53,14 +51,15 @@ public class RequestCacheAwareFilterTests { request.setScheme("https"); request.setServerPort(443); request.setSecure(true); - String encodedRedirectUrl = Base64.getEncoder().encodeToString("https://abc.com/destination".getBytes()); - Cookie savedRequest = new Cookie("REDIRECT_URI", encodedRedirectUrl); + MockHttpServletResponse response = new MockHttpServletResponse(); + cache.saveRequest(request, response); + Cookie savedRequest = response.getCookie("REDIRECT_URI"); savedRequest.setMaxAge(-1); savedRequest.setSecure(request.isSecure()); savedRequest.setPath("/"); savedRequest.setHttpOnly(true); request.setCookies(savedRequest); - MockHttpServletResponse response = new MockHttpServletResponse(); + response = new MockHttpServletResponse(); filter.doFilter(request, response, new MockFilterChain()); Cookie expiredCookie = response.getCookie("REDIRECT_URI"); assertThat(expiredCookie).isNotNull(); diff --git a/web/src/test/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCacheTests.java b/web/src/test/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCacheTests.java index 9dee46fe0b..1c3808d176 100644 --- a/web/src/test/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCacheTests.java +++ b/web/src/test/java/org/springframework/security/web/server/savedrequest/CookieServerRequestCacheTests.java @@ -117,6 +117,26 @@ public class CookieServerRequestCacheTests { assertThat(redirectUri).isNull(); } + @Test + public void getRedirectUriWhenCookieContainsAbsoluteUrlThenRedirectUriIsNull() { + String encodedAbsoluteUrl = Base64.getEncoder().encodeToString("https://evil.com/phishing".getBytes()); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login") + .accept(MediaType.TEXT_HTML) + .cookie(new HttpCookie("REDIRECT_URI", encodedAbsoluteUrl))); + URI redirectUri = this.cache.getRedirectUri(exchange).block(); + assertThat(redirectUri).isNull(); + } + + @Test + public void getRedirectUriWhenCookieContainsProtocolRelativeUrlThenRedirectUriIsNull() { + String encodedProtocolRelativeUrl = Base64.getEncoder().encodeToString("//evil.com/phishing".getBytes()); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login") + .accept(MediaType.TEXT_HTML) + .cookie(new HttpCookie("REDIRECT_URI", encodedProtocolRelativeUrl))); + URI redirectUri = this.cache.getRedirectUri(exchange).block(); + assertThat(redirectUri).isNull(); + } + @Test public void getRedirectUriWhenNoCookieThenRedirectUriIsNull() { MockServerWebExchange exchange = MockServerWebExchange From 356131b1ea6eede0c5306affab654f83a50d6bc9 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:12:56 -0600 Subject: [PATCH 3/6] Use FormPostRedirectStrategy Closes gh-16673 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- ...aml2WebSsoAuthenticationRequestFilter.java | 59 +++-------------- .../logout/Saml2LogoutRequestFilter.java | 65 +++++------------- ...ingPartyInitiatedLogoutSuccessHandler.java | 66 +++++-------------- ...ebSsoAuthenticationRequestFilterTests.java | 8 +-- .../logout/Saml2LogoutRequestFilterTests.java | 4 +- ...rtyInitiatedLogoutSuccessHandlerTests.java | 4 +- 6 files changed, 49 insertions(+), 157 deletions(-) diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2WebSsoAuthenticationRequestFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2WebSsoAuthenticationRequestFilter.java index 591040b410..93c7df405b 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2WebSsoAuthenticationRequestFilter.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2WebSsoAuthenticationRequestFilter.java @@ -24,17 +24,17 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.http.MediaType; import org.springframework.security.saml2.core.Saml2ParameterNames; import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.Saml2RedirectAuthenticationRequest; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.web.authentication.Saml2AuthenticationRequestResolver; +import org.springframework.security.web.FormPostRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.util.HtmlUtils; import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriUtils; @@ -67,6 +67,8 @@ public class Saml2WebSsoAuthenticationRequestFilter extends OncePerRequestFilter private Saml2AuthenticationRequestRepository authenticationRequestRepository = new HttpSessionSaml2AuthenticationRequestRepository(); + private final RedirectStrategy formPostRedirectStrategy = new FormPostRedirectStrategy(); + /** * Construct a {@link Saml2WebSsoAuthenticationRequestFilter} with the strategy for * resolving the {@code AuthnRequest} @@ -132,54 +134,11 @@ public class Saml2WebSsoAuthenticationRequestFilter extends OncePerRequestFilter private void sendPost(HttpServletRequest request, HttpServletResponse response, Saml2PostAuthenticationRequest authenticationRequest) throws IOException { this.authenticationRequestRepository.saveAuthenticationRequest(authenticationRequest, request, response); - String html = createSamlPostRequestFormData(authenticationRequest); - response.setContentType(MediaType.TEXT_HTML_VALUE); - response.getWriter().write(html); - } - - private String createSamlPostRequestFormData(Saml2PostAuthenticationRequest authenticationRequest) { - String authenticationRequestUri = authenticationRequest.getAuthenticationRequestUri(); - String relayState = authenticationRequest.getRelayState(); - String samlRequest = authenticationRequest.getSamlRequest(); - StringBuilder html = new StringBuilder(); - html.append("\n"); - html.append("\n").append(" \n"); - html.append(" Date: Thu, 30 Apr 2026 21:18:42 -0600 Subject: [PATCH 4/6] Rework Login Validation Logic This commit simplifies the validation code, favoring the construction of a list of error codes over using an accumulating Saml2ResponseValidationResult. This will simplify future analysis by making the code more readable. Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../BaseOpenSamlAuthenticationProvider.java | 61 +++++++++++-------- .../OpenSaml4AuthenticationProviderTests.java | 51 +++++++++++++++- .../OpenSaml5AuthenticationProviderTests.java | 49 ++++++++++++++- 3 files changed, 135 insertions(+), 26 deletions(-) diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java index 5c24acf2c7..8532c62fe4 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java @@ -326,62 +326,75 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { boolean responseSigned = response.isSigned(); ResponseToken responseToken = new ResponseToken(response, token); - Saml2ResponseValidatorResult result = this.responseSignatureValidator.convert(responseToken); + Collection responseSignatureErrors = this.responseSignatureValidator.convert(responseToken) + .getErrors(); + if (!responseSignatureErrors.isEmpty()) { + reportErrors(response, responseSignatureErrors); + return; + } + + Collection errors = new ArrayList<>(); if (responseSigned) { this.responseElementsDecrypter.accept(responseToken); } else if (!response.getEncryptedAssertions().isEmpty()) { - result = result.concat(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, "Did not decrypt response [" + response.getID() + "] since it is not signed")); } if (!this.validateResponseAfterAssertions) { - result = result.concat(this.responseValidator.convert(responseToken)); + errors.addAll(this.responseValidator.convert(responseToken).getErrors()); } boolean allAssertionsSigned = true; for (Assertion assertion : response.getAssertions()) { AssertionToken assertionToken = new AssertionToken(assertion, token); - result = result.concat(this.assertionSignatureValidator.convert(assertionToken)); + Collection assertionSignatureErrors = this.assertionSignatureValidator.convert(assertionToken) + .getErrors(); + errors.addAll(assertionSignatureErrors); allAssertionsSigned = allAssertionsSigned && assertion.isSigned(); + if (!assertionSignatureErrors.isEmpty()) { + continue; + } if (responseSigned || assertion.isSigned()) { this.assertionElementsDecrypter.accept(new AssertionToken(assertion, token)); } - result = result.concat(this.assertionValidator.convert(assertionToken)); + errors.addAll(this.assertionValidator.convert(assertionToken).getErrors()); } if (!responseSigned && !allAssertionsSigned) { String description = "Either the response or one of the assertions is unsigned. " + "Please either sign the response or all of the assertions."; - result = result.concat(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, description)); + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, description)); } if (this.validateResponseAfterAssertions) { - result = result.concat(this.responseValidator.convert(responseToken)); + errors.addAll(this.responseValidator.convert(responseToken).getErrors()); } else { Assertion firstAssertion = CollectionUtils.firstElement(response.getAssertions()); if (firstAssertion != null && !hasName(firstAssertion)) { - Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, - "Assertion [" + firstAssertion.getID() + "] is missing a subject"); - result = result.concat(error); + errors.add(new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, + "Assertion [" + firstAssertion.getID() + "] is missing a subject")); } } - if (result.hasErrors()) { - Collection errors = result.getErrors(); - if (this.logger.isTraceEnabled()) { - this.logger.trace("Found " + errors.size() + " validation errors in SAML response [" + response.getID() - + "]: " + errors); - } - else if (this.logger.isDebugEnabled()) { - this.logger - .debug("Found " + errors.size() + " validation errors in SAML response [" + response.getID() + "]"); - } - Saml2Error first = errors.iterator().next(); - throw createAuthenticationException(first.getErrorCode(), first.getDescription(), null); - } - else { + reportErrors(response, errors); + } + + private void reportErrors(Response response, Collection errors) { + if (errors.isEmpty()) { if (this.logger.isDebugEnabled()) { this.logger.debug("Successfully processed SAML Response [" + response.getID() + "]"); } + return; } + if (this.logger.isTraceEnabled()) { + this.logger.debug("Found " + errors.size() + " validation errors in SAML response [" + response.getID() + + "]: " + errors); + } + else if (this.logger.isDebugEnabled()) { + this.logger + .debug("Found " + errors.size() + " validation errors in SAML response [" + response.getID() + "]"); + } + Saml2Error first = errors.iterator().next(); + throw createAuthenticationException(first.getErrorCode(), first.getDescription(), null); } private Converter createDefaultResponseSignatureValidator() { diff --git a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProviderTests.java b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProviderTests.java index d9a4fd5e25..56af63f186 100644 --- a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProviderTests.java +++ b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProviderTests.java @@ -33,6 +33,7 @@ import javax.xml.namespace.QName; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.opensaml.core.xml.XMLObject; import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; import org.opensaml.core.xml.schema.XSDateTime; @@ -87,6 +88,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; /** * Tests for {@link OpenSaml4AuthenticationProvider} @@ -173,6 +175,53 @@ public class OpenSaml4AuthenticationProviderTests { .satisfies(errorOf(Saml2ErrorCodes.INVALID_SIGNATURE)); } + @Test + public void authenticateWhenResponseSignatureInvalidThenSkipsResponseAndAssertionValidation() { + Response response = response(); + response.setDestination(DESTINATION + "invalid"); + Assertion assertion = assertion(); + response.getAssertions().add(signed(assertion)); + // Sign the response with a credential the verifier does not trust + TestOpenSamlObjects.signed(response, TestSaml2X509Credentials.relyingPartyDecryptingCredential(), + RELYING_PARTY_ENTITY_ID); + OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider(); + Converter responseValidator = mock(Converter.class); + Converter assertionValidator = mock( + Converter.class); + provider.setResponseValidator(responseValidator); + provider.setAssertionValidator(assertionValidator); + Saml2AuthenticationToken token = token(response, verifying(registration())); + assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token)) + .satisfies(errorOf(Saml2ErrorCodes.INVALID_SIGNATURE)); + verifyNoInteractions(responseValidator, assertionValidator); + } + + @Test + public void authenticateWhenAssertionSignatureInvalidThenSkipsThatAssertionValidationOnly() { + Response response = response(); + Assertion bad = assertion(); + bad.setID("bad-assertion"); + TestOpenSamlObjects.signed(bad, TestSaml2X509Credentials.relyingPartyDecryptingCredential(), + RELYING_PARTY_ENTITY_ID); + response.getAssertions().add(bad); + Assertion good = assertion(); + good.setID("good-assertion"); + response.getAssertions().add(signed(good)); + OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider(); + Converter assertionValidator = mock( + Converter.class); + given(assertionValidator.convert(any(OpenSaml4AuthenticationProvider.AssertionToken.class))) + .willReturn(Saml2ResponseValidatorResult.success()); + provider.setAssertionValidator(assertionValidator); + Saml2AuthenticationToken token = token(signed(response), verifying(registration())); + assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token)) + .satisfies(errorOf(Saml2ErrorCodes.INVALID_SIGNATURE)); + ArgumentCaptor captor = ArgumentCaptor + .forClass(OpenSaml4AuthenticationProvider.AssertionToken.class); + verify(assertionValidator).convert(captor.capture()); + assertThat(captor.getValue().getAssertion().getID()).isEqualTo("good-assertion"); + } + @Test public void authenticateWhenOpenSAMLValidationErrorThenThrowAuthenticationException() { Response response = response(); @@ -502,7 +551,7 @@ public class OpenSaml4AuthenticationProviderTests { EncryptedAssertion encryptedAssertion = TestOpenSamlObjects.encrypted(assertion(), TestSaml2X509Credentials.assertingPartyEncryptingCredential()); response.getEncryptedAssertions().add(encryptedAssertion); - Saml2AuthenticationToken token = token(signed(response), registration() + Saml2AuthenticationToken token = token(signed(response), verifying(registration()) .decryptionX509Credentials((c) -> c.add(TestSaml2X509Credentials.assertingPartyPrivateCredential()))); assertThatExceptionOfType(Saml2AuthenticationException.class) .isThrownBy(() -> this.provider.authenticate(token)) diff --git a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java index 309f04bb16..df42a6f381 100644 --- a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java +++ b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java @@ -34,6 +34,7 @@ import javax.xml.namespace.QName; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.opensaml.core.xml.XMLObject; import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; import org.opensaml.core.xml.schema.XSDateTime; @@ -183,6 +184,52 @@ public class OpenSaml5AuthenticationProviderTests { .satisfies(errorOf(Saml2ErrorCodes.INVALID_SIGNATURE)); } + @Test + public void authenticateWhenResponseSignatureInvalidThenSkipsResponseAndAssertionValidation() { + Response response = response(); + response.setDestination(DESTINATION + "invalid"); + Assertion assertion = assertion(); + response.getAssertions().add(signed(assertion)); + TestOpenSamlObjects.signed(response, TestSaml2X509Credentials.relyingPartyDecryptingCredential(), + RELYING_PARTY_ENTITY_ID); + OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); + Converter responseValidator = mock(Converter.class); + Converter assertionValidator = mock( + Converter.class); + provider.setResponseValidator(responseValidator); + provider.setAssertionValidator(assertionValidator); + Saml2AuthenticationToken token = token(response, verifying(registration())); + assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token)) + .satisfies(errorOf(Saml2ErrorCodes.INVALID_SIGNATURE)); + verifyNoInteractions(responseValidator, assertionValidator); + } + + @Test + public void authenticateWhenAssertionSignatureInvalidThenSkipsThatAssertionValidationOnly() { + Response response = response(); + Assertion bad = assertion(); + bad.setID("bad-assertion"); + TestOpenSamlObjects.signed(bad, TestSaml2X509Credentials.relyingPartyDecryptingCredential(), + RELYING_PARTY_ENTITY_ID); + response.getAssertions().add(bad); + Assertion good = assertion(); + good.setID("good-assertion"); + response.getAssertions().add(signed(good)); + OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); + Converter assertionValidator = mock( + Converter.class); + given(assertionValidator.convert(any(OpenSaml5AuthenticationProvider.AssertionToken.class))) + .willReturn(Saml2ResponseValidatorResult.success()); + provider.setAssertionValidator(assertionValidator); + Saml2AuthenticationToken token = token(signed(response), verifying(registration())); + assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token)) + .satisfies(errorOf(Saml2ErrorCodes.INVALID_SIGNATURE)); + ArgumentCaptor captor = ArgumentCaptor + .forClass(OpenSaml5AuthenticationProvider.AssertionToken.class); + verify(assertionValidator).convert(captor.capture()); + assertThat(captor.getValue().getAssertion().getID()).isEqualTo("good-assertion"); + } + @Test public void authenticateWhenOpenSAMLValidationErrorThenThrowAuthenticationException() { Response response = response(); @@ -512,7 +559,7 @@ public class OpenSaml5AuthenticationProviderTests { EncryptedAssertion encryptedAssertion = TestOpenSamlObjects.encrypted(assertion(), TestSaml2X509Credentials.assertingPartyEncryptingCredential()); response.getEncryptedAssertions().add(encryptedAssertion); - Saml2AuthenticationToken token = token(signed(response), registration() + Saml2AuthenticationToken token = token(signed(response), verifying(registration()) .decryptionX509Credentials((c) -> c.add(TestSaml2X509Credentials.assertingPartyPrivateCredential()))); assertThatExceptionOfType(Saml2AuthenticationException.class) .isThrownBy(() -> this.provider.authenticate(token)) From cf0687120024c3dad09261dd9df4971b83717a53 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:18:01 -0600 Subject: [PATCH 5/6] Rework Logout Validation Logic This commit simplifies the code, favoring the construction of a list of error codes over composing a set of consumers. This will simplify future analysis by making the code more readable. Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../BaseOpenSamlLogoutRequestValidator.java | 126 +++++++------- .../BaseOpenSamlLogoutResponseValidator.java | 160 ++++++++---------- .../OpenSamlLogoutRequestValidator.java | 126 +++++++------- .../OpenSamlLogoutResponseValidator.java | 160 +++++++++--------- .../OpenSaml4LogoutRequestValidatorTests.java | 33 ++++ ...OpenSaml4LogoutResponseValidatorTests.java | 50 +++++- .../OpenSamlLogoutRequestValidatorTests.java | 33 ++++ .../OpenSamlLogoutResponseValidatorTests.java | 50 +++++- .../OpenSaml5LogoutRequestValidatorTests.java | 33 ++++ ...OpenSaml5LogoutResponseValidatorTests.java | 50 +++++- 10 files changed, 509 insertions(+), 312 deletions(-) diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutRequestValidator.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutRequestValidator.java index 8fb3c4c62b..b8b021ad97 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutRequestValidator.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutRequestValidator.java @@ -16,8 +16,9 @@ package org.springframework.security.saml2.provider.service.authentication.logout; +import java.util.ArrayList; import java.util.Collection; -import java.util.function.Consumer; +import java.util.Collections; import org.opensaml.saml.saml2.core.LogoutRequest; import org.opensaml.saml.saml2.core.NameID; @@ -56,84 +57,74 @@ class BaseOpenSamlLogoutRequestValidator implements Saml2LogoutRequestValidator LogoutRequest logoutRequest = this.saml.deserialize(Saml2Utils.withEncoded(request.getSamlRequest()) .inflate(request.getBinding() == Saml2MessageBinding.REDIRECT) .decode()); - return Saml2LogoutValidatorResult.withErrors() - .errors(verifySignature(request, logoutRequest, registration)) - .errors(validateRequest(logoutRequest, registration, authentication)) - .build(); + Collection errors = verifySignature(request, logoutRequest, registration); + if (!errors.isEmpty()) { + return Saml2LogoutValidatorResult.withErrors(errors.toArray(Saml2Error[]::new)).build(); + } + errors = validateRequest(logoutRequest, registration, authentication); + return errors.isEmpty() ? Saml2LogoutValidatorResult.success() + : Saml2LogoutValidatorResult.withErrors(errors.toArray(Saml2Error[]::new)).build(); } - private Consumer> verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest, + private Collection verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest, RelyingPartyRegistration registration) { AssertingPartyMetadata details = registration.getAssertingPartyMetadata(); Collection credentials = details.getVerificationX509Credentials(); VerificationConfigurer verify = this.saml.withVerificationKeys(credentials).entityId(details.getEntityId()); - return (errors) -> { - if (logoutRequest.isSigned()) { - errors.addAll(verify.verify(logoutRequest)); - } - else { - RedirectParameters params = new RedirectParameters(request.getParameters(), - request.getParametersQuery(), logoutRequest); - errors.addAll(verify.verify(params)); - } - }; + if (logoutRequest.isSigned()) { + return verify.verify(logoutRequest); + } + RedirectParameters params = new RedirectParameters(request.getParameters(), request.getParametersQuery(), + logoutRequest); + return verify.verify(params); } - private Consumer> validateRequest(LogoutRequest request, - RelyingPartyRegistration registration, Authentication authentication) { - return (errors) -> { - validateIssuer(request, registration).accept(errors); - validateDestination(request, registration).accept(errors); - validateSubject(request, registration, authentication).accept(errors); - }; + private Collection validateRequest(LogoutRequest request, RelyingPartyRegistration registration, + Authentication authentication) { + Collection errors = new ArrayList<>(); + errors.addAll(validateIssuer(request, registration)); + errors.addAll(validateDestination(request, registration)); + errors.addAll(validateSubject(request, registration, authentication)); + return errors; } - private Consumer> validateIssuer(LogoutRequest request, - RelyingPartyRegistration registration) { - return (errors) -> { - if (request.getIssuer() == null) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutRequest")); - return; - } - String issuer = request.getIssuer().getValue(); - if (!issuer.equals(registration.getAssertingPartyMetadata().getEntityId())) { - errors - .add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); - } - }; + private Collection validateIssuer(LogoutRequest request, RelyingPartyRegistration registration) { + if (request.getIssuer() == null) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutRequest")); + } + String issuer = request.getIssuer().getValue(); + if (!issuer.equals(registration.getAssertingPartyMetadata().getEntityId())) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); + } + return Collections.emptyList(); } - private Consumer> validateDestination(LogoutRequest request, - RelyingPartyRegistration registration) { - return (errors) -> { - if (request.getDestination() == null) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, - "Failed to find destination in LogoutRequest")); - return; - } - String destination = request.getDestination(); - if (!destination.equals(registration.getSingleLogoutServiceLocation())) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, - "Failed to match destination to configured destination")); - } - }; + private Collection validateDestination(LogoutRequest request, RelyingPartyRegistration registration) { + if (request.getDestination() == null) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, "Failed to find destination in LogoutRequest")); + } + String destination = request.getDestination(); + if (!destination.equals(registration.getSingleLogoutServiceLocation())) { + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to match destination to configured destination")); + } + return Collections.emptyList(); } - private Consumer> validateSubject(LogoutRequest request, - RelyingPartyRegistration registration, Authentication authentication) { - return (errors) -> { - if (authentication == null) { - return; - } - NameID nameId = getNameId(request, registration); - if (nameId == null) { - errors - .add(new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, "Failed to find subject in LogoutRequest")); - return; - } - - validateNameId(nameId, authentication, errors); - }; + private Collection validateSubject(LogoutRequest request, RelyingPartyRegistration registration, + Authentication authentication) { + if (authentication == null) { + return Collections.emptyList(); + } + NameID nameId = getNameId(request, registration); + if (nameId == null) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, "Failed to find subject in LogoutRequest")); + } + return validateNameId(nameId, authentication); } private NameID getNameId(LogoutRequest request, RelyingPartyRegistration registration) { @@ -141,12 +132,13 @@ class BaseOpenSamlLogoutRequestValidator implements Saml2LogoutRequestValidator return request.getNameID(); } - private void validateNameId(NameID nameId, Authentication authentication, Collection errors) { + private Collection validateNameId(NameID nameId, Authentication authentication) { String name = nameId.getValue(); if (!name.equals(authentication.getName())) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, "Failed to match subject in LogoutRequest with currently logged in user")); } + return Collections.emptyList(); } } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutResponseValidator.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutResponseValidator.java index 92305d8610..73480fbdf9 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutResponseValidator.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutResponseValidator.java @@ -16,8 +16,9 @@ package org.springframework.security.saml2.provider.service.authentication.logout; +import java.util.ArrayList; import java.util.Collection; -import java.util.function.Consumer; +import java.util.Collections; import org.opensaml.saml.saml2.core.LogoutResponse; import org.opensaml.saml.saml2.core.StatusCode; @@ -52,101 +53,90 @@ class BaseOpenSamlLogoutResponseValidator implements Saml2LogoutResponseValidato LogoutResponse logoutResponse = this.saml.deserialize(Saml2Utils.withEncoded(response.getSamlResponse()) .inflate(response.getBinding() == Saml2MessageBinding.REDIRECT) .decode()); - return Saml2LogoutValidatorResult.withErrors() - .errors(verifySignature(response, logoutResponse, registration)) - .errors(validateRequest(logoutResponse, registration)) - .errors(validateLogoutRequest(logoutResponse, request.getId())) - .build(); + Collection errors = verifySignature(response, logoutResponse, registration); + if (!errors.isEmpty()) { + return Saml2LogoutValidatorResult.withErrors(errors.toArray(Saml2Error[]::new)).build(); + } + errors = validateRequest(logoutResponse, registration, request.getId()); + return errors.isEmpty() ? Saml2LogoutValidatorResult.success() + : Saml2LogoutValidatorResult.withErrors(errors.toArray(Saml2Error[]::new)).build(); } - private Consumer> verifySignature(Saml2LogoutResponse response, - LogoutResponse logoutResponse, RelyingPartyRegistration registration) { - return (errors) -> { - AssertingPartyMetadata details = registration.getAssertingPartyMetadata(); - Collection credentials = details.getVerificationX509Credentials(); - VerificationConfigurer verify = this.saml.withVerificationKeys(credentials) - .entityId(details.getEntityId()) - .entityId(details.getEntityId()); - if (logoutResponse.isSigned()) { - errors.addAll(verify.verify(logoutResponse)); - } - else { - RedirectParameters params = new RedirectParameters(response.getParameters(), - response.getParametersQuery(), logoutResponse); - errors.addAll(verify.verify(params)); - } - }; - } - - private Consumer> validateRequest(LogoutResponse response, + private Collection verifySignature(Saml2LogoutResponse response, LogoutResponse logoutResponse, RelyingPartyRegistration registration) { - return (errors) -> { - validateIssuer(response, registration).accept(errors); - validateDestination(response, registration).accept(errors); - validateStatus(response).accept(errors); - }; + AssertingPartyMetadata details = registration.getAssertingPartyMetadata(); + Collection credentials = details.getVerificationX509Credentials(); + VerificationConfigurer verify = this.saml.withVerificationKeys(credentials).entityId(details.getEntityId()); + if (logoutResponse.isSigned()) { + return verify.verify(logoutResponse); + } + RedirectParameters params = new RedirectParameters(response.getParameters(), response.getParametersQuery(), + logoutResponse); + return verify.verify(params); } - private Consumer> validateIssuer(LogoutResponse response, - RelyingPartyRegistration registration) { - return (errors) -> { - if (response.getIssuer() == null) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse")); - return; - } - String issuer = response.getIssuer().getValue(); - if (!issuer.equals(registration.getAssertingPartyMetadata().getEntityId())) { - errors - .add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); - } - }; + private Collection validateRequest(LogoutResponse response, RelyingPartyRegistration registration, + String logoutRequestId) { + Collection errors = new ArrayList<>(); + errors.addAll(validateIssuer(response, registration)); + errors.addAll(validateDestination(response, registration)); + errors.addAll(validateStatus(response)); + errors.addAll(validateLogoutRequest(response, logoutRequestId)); + return errors; } - private Consumer> validateDestination(LogoutResponse response, - RelyingPartyRegistration registration) { - return (errors) -> { - if (response.getDestination() == null) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, - "Failed to find destination in LogoutResponse")); - return; - } - String destination = response.getDestination(); - if (!destination.equals(registration.getSingleLogoutServiceResponseLocation())) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, - "Failed to match destination to configured destination")); - } - }; + private Collection validateIssuer(LogoutResponse response, RelyingPartyRegistration registration) { + if (response.getIssuer() == null) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse")); + } + String issuer = response.getIssuer().getValue(); + if (!issuer.equals(registration.getAssertingPartyMetadata().getEntityId())) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); + } + return Collections.emptyList(); } - private Consumer> validateStatus(LogoutResponse response) { - return (errors) -> { - if (response.getStatus() == null) { - return; - } - if (response.getStatus().getStatusCode() == null) { - return; - } - if (StatusCode.SUCCESS.equals(response.getStatus().getStatusCode().getValue())) { - return; - } - if (StatusCode.PARTIAL_LOGOUT.equals(response.getStatus().getStatusCode().getValue())) { - return; - } - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, "Response indicated logout failed")); - }; + private Collection validateDestination(LogoutResponse response, RelyingPartyRegistration registration) { + if (response.getDestination() == null) { + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to find destination in LogoutResponse")); + } + String destination = response.getDestination(); + if (!destination.equals(registration.getSingleLogoutServiceResponseLocation())) { + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to match destination to configured destination")); + } + return Collections.emptyList(); } - private Consumer> validateLogoutRequest(LogoutResponse response, String id) { - return (errors) -> { - if (response.getInResponseTo() == null) { - return; - } - if (response.getInResponseTo().equals(id)) { - return; - } - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, - "LogoutResponse InResponseTo doesn't match ID of associated LogoutRequest")); - }; + private Collection validateStatus(LogoutResponse response) { + if (response.getStatus() == null) { + return Collections.emptyList(); + } + if (response.getStatus().getStatusCode() == null) { + return Collections.emptyList(); + } + if (StatusCode.SUCCESS.equals(response.getStatus().getStatusCode().getValue())) { + return Collections.emptyList(); + } + if (StatusCode.PARTIAL_LOGOUT.equals(response.getStatus().getStatusCode().getValue())) { + return Collections.emptyList(); + } + return Collections + .singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, "Response indicated logout failed")); + } + + private Collection validateLogoutRequest(LogoutResponse response, String id) { + if (response.getInResponseTo() == null) { + return Collections.emptyList(); + } + if (response.getInResponseTo().equals(id)) { + return Collections.emptyList(); + } + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, + "LogoutResponse InResponseTo doesn't match ID of associated LogoutRequest")); } } diff --git a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java index 59c35c00f3..870d5bdf4d 100644 --- a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java +++ b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java @@ -16,8 +16,9 @@ package org.springframework.security.saml2.provider.service.authentication.logout; +import java.util.ArrayList; import java.util.Collection; -import java.util.function.Consumer; +import java.util.Collections; import org.opensaml.saml.saml2.core.LogoutRequest; import org.opensaml.saml.saml2.core.NameID; @@ -68,84 +69,74 @@ public final class OpenSamlLogoutRequestValidator implements Saml2LogoutRequestV LogoutRequest logoutRequest = this.saml.deserialize(Saml2Utils.withEncoded(request.getSamlRequest()) .inflate(request.getBinding() == Saml2MessageBinding.REDIRECT) .decode()); - return Saml2LogoutValidatorResult.withErrors() - .errors(verifySignature(request, logoutRequest, registration)) - .errors(validateRequest(logoutRequest, registration, authentication)) - .build(); + Collection errors = verifySignature(request, logoutRequest, registration); + if (!errors.isEmpty()) { + return Saml2LogoutValidatorResult.withErrors(errors.toArray(Saml2Error[]::new)).build(); + } + errors = validateRequest(logoutRequest, registration, authentication); + return errors.isEmpty() ? Saml2LogoutValidatorResult.success() + : Saml2LogoutValidatorResult.withErrors(errors.toArray(Saml2Error[]::new)).build(); } - private Consumer> verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest, + private Collection verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest, RelyingPartyRegistration registration) { AssertingPartyMetadata details = registration.getAssertingPartyMetadata(); Collection credentials = details.getVerificationX509Credentials(); VerificationConfigurer verify = this.saml.withVerificationKeys(credentials).entityId(details.getEntityId()); - return (errors) -> { - if (logoutRequest.isSigned()) { - errors.addAll(verify.verify(logoutRequest)); - } - else { - RedirectParameters params = new RedirectParameters(request.getParameters(), - request.getParametersQuery(), logoutRequest); - errors.addAll(verify.verify(params)); - } - }; + if (logoutRequest.isSigned()) { + return verify.verify(logoutRequest); + } + RedirectParameters params = new RedirectParameters(request.getParameters(), request.getParametersQuery(), + logoutRequest); + return verify.verify(params); } - private Consumer> validateRequest(LogoutRequest request, - RelyingPartyRegistration registration, Authentication authentication) { - return (errors) -> { - validateIssuer(request, registration).accept(errors); - validateDestination(request, registration).accept(errors); - validateSubject(request, registration, authentication).accept(errors); - }; + private Collection validateRequest(LogoutRequest request, RelyingPartyRegistration registration, + Authentication authentication) { + Collection errors = new ArrayList<>(); + errors.addAll(validateIssuer(request, registration)); + errors.addAll(validateDestination(request, registration)); + errors.addAll(validateSubject(request, registration, authentication)); + return errors; } - private Consumer> validateIssuer(LogoutRequest request, - RelyingPartyRegistration registration) { - return (errors) -> { - if (request.getIssuer() == null) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutRequest")); - return; - } - String issuer = request.getIssuer().getValue(); - if (!issuer.equals(registration.getAssertingPartyMetadata().getEntityId())) { - errors - .add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); - } - }; + private Collection validateIssuer(LogoutRequest request, RelyingPartyRegistration registration) { + if (request.getIssuer() == null) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutRequest")); + } + String issuer = request.getIssuer().getValue(); + if (!issuer.equals(registration.getAssertingPartyMetadata().getEntityId())) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); + } + return Collections.emptyList(); } - private Consumer> validateDestination(LogoutRequest request, - RelyingPartyRegistration registration) { - return (errors) -> { - if (request.getDestination() == null) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, - "Failed to find destination in LogoutRequest")); - return; - } - String destination = request.getDestination(); - if (!destination.equals(registration.getSingleLogoutServiceLocation())) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, - "Failed to match destination to configured destination")); - } - }; + private Collection validateDestination(LogoutRequest request, RelyingPartyRegistration registration) { + if (request.getDestination() == null) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, "Failed to find destination in LogoutRequest")); + } + String destination = request.getDestination(); + if (!destination.equals(registration.getSingleLogoutServiceLocation())) { + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to match destination to configured destination")); + } + return Collections.emptyList(); } - private Consumer> validateSubject(LogoutRequest request, - RelyingPartyRegistration registration, Authentication authentication) { - return (errors) -> { - if (authentication == null) { - return; - } - NameID nameId = getNameId(request, registration); - if (nameId == null) { - errors - .add(new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, "Failed to find subject in LogoutRequest")); - return; - } - - validateNameId(nameId, authentication, errors); - }; + private Collection validateSubject(LogoutRequest request, RelyingPartyRegistration registration, + Authentication authentication) { + if (authentication == null) { + return Collections.emptyList(); + } + NameID nameId = getNameId(request, registration); + if (nameId == null) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, "Failed to find subject in LogoutRequest")); + } + return validateNameId(nameId, authentication); } private NameID getNameId(LogoutRequest request, RelyingPartyRegistration registration) { @@ -153,12 +144,13 @@ public final class OpenSamlLogoutRequestValidator implements Saml2LogoutRequestV return request.getNameID(); } - private void validateNameId(NameID nameId, Authentication authentication, Collection errors) { + private Collection validateNameId(NameID nameId, Authentication authentication) { String name = nameId.getValue(); if (!name.equals(authentication.getName())) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, "Failed to match subject in LogoutRequest with currently logged in user")); } + return Collections.emptyList(); } } diff --git a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java index 4d21f3acea..f42612eb73 100644 --- a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java +++ b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java @@ -16,8 +16,9 @@ package org.springframework.security.saml2.provider.service.authentication.logout; +import java.util.ArrayList; import java.util.Collection; -import java.util.function.Consumer; +import java.util.Collections; import org.opensaml.core.config.ConfigurationService; import org.opensaml.core.xml.config.XMLObjectProviderRegistry; @@ -59,7 +60,7 @@ public class OpenSamlLogoutResponseValidator implements Saml2LogoutResponseValid private final OpenSamlOperations saml = new OpenSaml4Template(); /** - * Constructs a {@link OpenSamlLogoutRequestValidator} + * Constructs an {@link OpenSamlLogoutResponseValidator} */ public OpenSamlLogoutResponseValidator() { this.registry = ConfigurationService.get(XMLObjectProviderRegistry.class); @@ -78,99 +79,90 @@ public class OpenSamlLogoutResponseValidator implements Saml2LogoutResponseValid LogoutResponse logoutResponse = this.saml.deserialize(Saml2Utils.withEncoded(response.getSamlResponse()) .inflate(response.getBinding() == Saml2MessageBinding.REDIRECT) .decode()); - return Saml2LogoutValidatorResult.withErrors() - .errors(verifySignature(response, logoutResponse, registration)) - .errors(validateRequest(logoutResponse, registration)) - .errors(validateLogoutRequest(logoutResponse, request.getId())) - .build(); + Collection errors = verifySignature(response, logoutResponse, registration); + if (!errors.isEmpty()) { + return Saml2LogoutValidatorResult.withErrors(errors.toArray(Saml2Error[]::new)).build(); + } + errors = validateRequest(logoutResponse, registration, request.getId()); + return errors.isEmpty() ? Saml2LogoutValidatorResult.success() + : Saml2LogoutValidatorResult.withErrors(errors.toArray(Saml2Error[]::new)).build(); } - private Consumer> verifySignature(Saml2LogoutResponse response, - LogoutResponse logoutResponse, RelyingPartyRegistration registration) { - return (errors) -> { - AssertingPartyMetadata details = registration.getAssertingPartyMetadata(); - Collection credentials = details.getVerificationX509Credentials(); - VerificationConfigurer verify = this.saml.withVerificationKeys(credentials).entityId(details.getEntityId()); - if (logoutResponse.isSigned()) { - errors.addAll(verify.verify(logoutResponse)); - } - else { - RedirectParameters params = new RedirectParameters(response.getParameters(), - response.getParametersQuery(), logoutResponse); - errors.addAll(verify.verify(params)); - } - }; - } - - private Consumer> validateRequest(LogoutResponse response, + private Collection verifySignature(Saml2LogoutResponse response, LogoutResponse logoutResponse, RelyingPartyRegistration registration) { - return (errors) -> { - validateIssuer(response, registration).accept(errors); - validateDestination(response, registration).accept(errors); - validateStatus(response).accept(errors); - }; + AssertingPartyMetadata details = registration.getAssertingPartyMetadata(); + Collection credentials = details.getVerificationX509Credentials(); + VerificationConfigurer verify = this.saml.withVerificationKeys(credentials).entityId(details.getEntityId()); + if (logoutResponse.isSigned()) { + return verify.verify(logoutResponse); + } + RedirectParameters params = new RedirectParameters(response.getParameters(), response.getParametersQuery(), + logoutResponse); + return verify.verify(params); } - private Consumer> validateIssuer(LogoutResponse response, - RelyingPartyRegistration registration) { - return (errors) -> { - if (response.getIssuer() == null) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse")); - return; - } - String issuer = response.getIssuer().getValue(); - if (!issuer.equals(registration.getAssertingPartyMetadata().getEntityId())) { - errors - .add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); - } - }; + private Collection validateRequest(LogoutResponse response, RelyingPartyRegistration registration, + String logoutRequestId) { + Collection errors = new ArrayList<>(); + errors.addAll(validateIssuer(response, registration)); + errors.addAll(validateDestination(response, registration)); + errors.addAll(validateStatus(response)); + errors.addAll(validateLogoutRequest(response, logoutRequestId)); + return errors; } - private Consumer> validateDestination(LogoutResponse response, - RelyingPartyRegistration registration) { - return (errors) -> { - if (response.getDestination() == null) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, - "Failed to find destination in LogoutResponse")); - return; - } - String destination = response.getDestination(); - if (!destination.equals(registration.getSingleLogoutServiceResponseLocation())) { - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, - "Failed to match destination to configured destination")); - } - }; + private Collection validateIssuer(LogoutResponse response, RelyingPartyRegistration registration) { + if (response.getIssuer() == null) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse")); + } + String issuer = response.getIssuer().getValue(); + if (!issuer.equals(registration.getAssertingPartyMetadata().getEntityId())) { + return Collections.singletonList( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); + } + return Collections.emptyList(); } - private Consumer> validateStatus(LogoutResponse response) { - return (errors) -> { - if (response.getStatus() == null) { - return; - } - if (response.getStatus().getStatusCode() == null) { - return; - } - if (StatusCode.SUCCESS.equals(response.getStatus().getStatusCode().getValue())) { - return; - } - if (StatusCode.PARTIAL_LOGOUT.equals(response.getStatus().getStatusCode().getValue())) { - return; - } - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, "Response indicated logout failed")); - }; + private Collection validateDestination(LogoutResponse response, RelyingPartyRegistration registration) { + if (response.getDestination() == null) { + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to find destination in LogoutResponse")); + } + String destination = response.getDestination(); + if (!destination.equals(registration.getSingleLogoutServiceResponseLocation())) { + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to match destination to configured destination")); + } + return Collections.emptyList(); } - private Consumer> validateLogoutRequest(LogoutResponse response, String id) { - return (errors) -> { - if (response.getInResponseTo() == null) { - return; - } - if (response.getInResponseTo().equals(id)) { - return; - } - errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, - "LogoutResponse InResponseTo doesn't match ID of associated LogoutRequest")); - }; + private Collection validateStatus(LogoutResponse response) { + if (response.getStatus() == null) { + return Collections.emptyList(); + } + if (response.getStatus().getStatusCode() == null) { + return Collections.emptyList(); + } + if (StatusCode.SUCCESS.equals(response.getStatus().getStatusCode().getValue())) { + return Collections.emptyList(); + } + if (StatusCode.PARTIAL_LOGOUT.equals(response.getStatus().getStatusCode().getValue())) { + return Collections.emptyList(); + } + return Collections + .singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, "Response indicated logout failed")); + } + + private Collection validateLogoutRequest(LogoutResponse response, String id) { + if (response.getInResponseTo() == null) { + return Collections.emptyList(); + } + if (response.getInResponseTo().equals(id)) { + return Collections.emptyList(); + } + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, + "LogoutResponse InResponseTo doesn't match ID of associated LogoutRequest")); } } diff --git a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml4LogoutRequestValidatorTests.java b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml4LogoutRequestValidatorTests.java index 3e77d0f70f..3c4c1ced60 100644 --- a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml4LogoutRequestValidatorTests.java +++ b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml4LogoutRequestValidatorTests.java @@ -26,6 +26,7 @@ import org.opensaml.core.xml.XMLObject; import org.opensaml.saml.saml2.core.LogoutRequest; import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ParameterNames; import org.springframework.security.saml2.core.TestSaml2X509Credentials; @@ -90,6 +91,38 @@ public class OpenSaml4LogoutRequestValidatorTests { assertThat(result.hasErrors()).isFalse(); } + @Test + public void handleWhenSignatureVerificationFailsThenDoesNotValidateRequestFurther() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + sign(logoutRequest, registration); + logoutRequest.getIssuer().setValue("https://other-asserting-party.example"); + logoutRequest.setDestination("https://wrong-destination.example"); + logoutRequest.getNameID().setValue("someone-else"); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.validator.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsOnly(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenDestinationAndNameIdInvalidThenCollectsAllApplicableRequestErrors() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.setDestination("https://wrong-destination.example"); + logoutRequest.getNameID().setValue("wrong-user"); + sign(logoutRequest, registration); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.validator.validate(parameters); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsExactly(Saml2ErrorCodes.INVALID_DESTINATION, Saml2ErrorCodes.INVALID_REQUEST); + } + @Test public void handleWhenInvalidIssuerThenInvalidSignatureError() { RelyingPartyRegistration registration = registration().build(); diff --git a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml4LogoutResponseValidatorTests.java b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml4LogoutResponseValidatorTests.java index 0a84829704..9c21b1bbab 100644 --- a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml4LogoutResponseValidatorTests.java +++ b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml4LogoutResponseValidatorTests.java @@ -24,6 +24,7 @@ import org.opensaml.core.xml.XMLObject; import org.opensaml.saml.saml2.core.LogoutResponse; import org.opensaml.saml.saml2.core.StatusCode; +import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ParameterNames; import org.springframework.security.saml2.core.TestSaml2X509Credentials; @@ -57,7 +58,8 @@ public class OpenSaml4LogoutResponseValidatorTests { Saml2LogoutResponse response = post(logoutResponse, registration); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); } @Test @@ -73,7 +75,48 @@ public class OpenSaml4LogoutResponseValidatorTests { this.saml.withSigningKeys(registration.getSigningX509Credentials())); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); + } + + @Test + public void handleWhenSignatureVerificationFailsThenDoesNotValidateResponseFurther() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + sign(logoutResponse, registration); + logoutResponse.setDestination("https://wrong-destination.example"); + logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL); + logoutResponse.setInResponseTo("wrong-id"); + Saml2LogoutResponse response = post(logoutResponse, registration); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, + logoutRequest, registration); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsOnly(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenDestinationStatusAndInResponseToInvalidThenCollectsAllApplicableRequestErrors() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.setDestination("https://wrong-destination.example"); + logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL); + logoutResponse.setInResponseTo("wrong-id"); + sign(logoutResponse, registration); + Saml2LogoutResponse response = post(logoutResponse, registration); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, + logoutRequest, registration); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsExactly(Saml2ErrorCodes.INVALID_DESTINATION, Saml2ErrorCodes.INVALID_RESPONSE, + Saml2ErrorCodes.INVALID_RESPONSE); } @Test @@ -145,7 +188,8 @@ public class OpenSaml4LogoutResponseValidatorTests { .build(); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); } private RelyingPartyRegistration.Builder registration() { diff --git a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java index ebb8dab07c..ffc3ee3951 100644 --- a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java +++ b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java @@ -26,6 +26,7 @@ import org.opensaml.core.xml.XMLObject; import org.opensaml.saml.saml2.core.LogoutRequest; import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ParameterNames; import org.springframework.security.saml2.core.TestSaml2X509Credentials; @@ -90,6 +91,38 @@ public class OpenSamlLogoutRequestValidatorTests { assertThat(result.hasErrors()).isFalse(); } + @Test + public void handleWhenSignatureVerificationFailsThenDoesNotValidateRequestFurther() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + sign(logoutRequest, registration); + logoutRequest.getIssuer().setValue("https://other-asserting-party.example"); + logoutRequest.setDestination("https://wrong-destination.example"); + logoutRequest.getNameID().setValue("someone-else"); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsOnly(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenDestinationAndNameIdInvalidThenCollectsAllApplicableRequestErrors() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.setDestination("https://wrong-destination.example"); + logoutRequest.getNameID().setValue("wrong-user"); + sign(logoutRequest, registration); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsExactly(Saml2ErrorCodes.INVALID_DESTINATION, Saml2ErrorCodes.INVALID_REQUEST); + } + @Test public void handleWhenInvalidIssuerThenInvalidSignatureError() { RelyingPartyRegistration registration = registration().build(); diff --git a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java index 5b9eeed6dd..72e0a63e04 100644 --- a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java +++ b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java @@ -24,6 +24,7 @@ import org.opensaml.core.xml.XMLObject; import org.opensaml.saml.saml2.core.LogoutResponse; import org.opensaml.saml.saml2.core.StatusCode; +import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ParameterNames; import org.springframework.security.saml2.core.TestSaml2X509Credentials; @@ -57,7 +58,8 @@ public class OpenSamlLogoutResponseValidatorTests { Saml2LogoutResponse response = post(logoutResponse, registration); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); } @Test @@ -73,7 +75,48 @@ public class OpenSamlLogoutResponseValidatorTests { this.saml.withSigningKeys(registration.getSigningX509Credentials())); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); + } + + @Test + public void handleWhenSignatureVerificationFailsThenDoesNotValidateResponseFurther() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + sign(logoutResponse, registration); + logoutResponse.setDestination("https://wrong-destination.example"); + logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL); + logoutResponse.setInResponseTo("wrong-id"); + Saml2LogoutResponse response = post(logoutResponse, registration); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, + logoutRequest, registration); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsOnly(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenDestinationStatusAndInResponseToInvalidThenCollectsAllApplicableRequestErrors() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.setDestination("https://wrong-destination.example"); + logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL); + logoutResponse.setInResponseTo("wrong-id"); + sign(logoutResponse, registration); + Saml2LogoutResponse response = post(logoutResponse, registration); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, + logoutRequest, registration); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsExactly(Saml2ErrorCodes.INVALID_DESTINATION, Saml2ErrorCodes.INVALID_RESPONSE, + Saml2ErrorCodes.INVALID_RESPONSE); } @Test @@ -145,7 +188,8 @@ public class OpenSamlLogoutResponseValidatorTests { .build(); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); } private RelyingPartyRegistration.Builder registration() { diff --git a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml5LogoutRequestValidatorTests.java b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml5LogoutRequestValidatorTests.java index 0bb4ff9f36..7421b9dc6b 100644 --- a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml5LogoutRequestValidatorTests.java +++ b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml5LogoutRequestValidatorTests.java @@ -26,6 +26,7 @@ import org.opensaml.core.xml.XMLObject; import org.opensaml.saml.saml2.core.LogoutRequest; import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ParameterNames; import org.springframework.security.saml2.core.TestSaml2X509Credentials; @@ -90,6 +91,38 @@ public class OpenSaml5LogoutRequestValidatorTests { assertThat(result.hasErrors()).isFalse(); } + @Test + public void handleWhenSignatureVerificationFailsThenDoesNotValidateRequestFurther() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + sign(logoutRequest, registration); + logoutRequest.getIssuer().setValue("https://other-asserting-party.example"); + logoutRequest.setDestination("https://wrong-destination.example"); + logoutRequest.getNameID().setValue("someone-else"); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.validator.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsOnly(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenDestinationAndNameIdInvalidThenCollectsAllApplicableRequestErrors() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.setDestination("https://wrong-destination.example"); + logoutRequest.getNameID().setValue("wrong-user"); + sign(logoutRequest, registration); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.validator.validate(parameters); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsExactly(Saml2ErrorCodes.INVALID_DESTINATION, Saml2ErrorCodes.INVALID_REQUEST); + } + @Test public void handleWhenInvalidIssuerThenInvalidSignatureError() { RelyingPartyRegistration registration = registration().build(); diff --git a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml5LogoutResponseValidatorTests.java b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml5LogoutResponseValidatorTests.java index e081bafc9d..6bae7e565c 100644 --- a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml5LogoutResponseValidatorTests.java +++ b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSaml5LogoutResponseValidatorTests.java @@ -24,6 +24,7 @@ import org.opensaml.core.xml.XMLObject; import org.opensaml.saml.saml2.core.LogoutResponse; import org.opensaml.saml.saml2.core.StatusCode; +import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ParameterNames; import org.springframework.security.saml2.core.TestSaml2X509Credentials; @@ -57,7 +58,8 @@ public class OpenSaml5LogoutResponseValidatorTests { Saml2LogoutResponse response = post(logoutResponse, registration); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); } @Test @@ -73,7 +75,48 @@ public class OpenSaml5LogoutResponseValidatorTests { this.saml.withSigningKeys(registration.getSigningX509Credentials())); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); + } + + @Test + public void handleWhenSignatureVerificationFailsThenDoesNotValidateResponseFurther() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + sign(logoutResponse, registration); + logoutResponse.setDestination("https://wrong-destination.example"); + logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL); + logoutResponse.setInResponseTo("wrong-id"); + Saml2LogoutResponse response = post(logoutResponse, registration); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, + logoutRequest, registration); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsOnly(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenDestinationStatusAndInResponseToInvalidThenCollectsAllApplicableRequestErrors() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.setDestination("https://wrong-destination.example"); + logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL); + logoutResponse.setInResponseTo("wrong-id"); + sign(logoutResponse, registration); + Saml2LogoutResponse response = post(logoutResponse, registration); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, + logoutRequest, registration); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.getErrors()).extracting(Saml2Error::getErrorCode) + .containsExactly(Saml2ErrorCodes.INVALID_DESTINATION, Saml2ErrorCodes.INVALID_RESPONSE, + Saml2ErrorCodes.INVALID_RESPONSE); } @Test @@ -145,7 +188,8 @@ public class OpenSaml5LogoutResponseValidatorTests { .build(); Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, logoutRequest, registration); - this.manager.validate(parameters); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); } private RelyingPartyRegistration.Builder registration() { From e3ad551ab337d8a1e17217994b45fed6c68ce69e Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Fri, 16 May 2025 14:07:10 +0300 Subject: [PATCH 6/6] Backport SubjectX500PrincipalExtractor This commit backports SubjectX500PrincipalExtractor so as to provide folks moving from 6.x to 7.x a migration path from SubjectDnX509PrincipalExtractor. Issue gh-16980 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../web/configurers/X509Configurer.java | 4 + .../http/AuthenticationConfigBuilder.java | 18 ++- .../config/web/server/ServerHttpSecurity.java | 12 +- .../security/config/spring-security-6.5.rnc | 3 + .../security/config/spring-security-6.5.xsd | 6 + .../web/configurers/X509ConfigurerTests.java | 80 ++++++++++ .../config/http/MiscHttpConfigTests.java | 23 +++ ...pConfigTests-X509PrincipalExtractorRef.xml | 41 +++++ ...alExtractorRefAndSubjectPrincipalRegex.xml | 41 +++++ docs/antora.yml | 1 + .../pages/reactive/authentication/x509.adoc | 94 +---------- .../servlet/appendix/namespace/http.adoc | 5 +- .../pages/servlet/authentication/x509.adoc | 42 ++--- .../reactivex509/CustomX509Configuration.java | 74 +++++++++ .../DefaultX509Configuration.java | 67 ++++++++ .../reactivex509/X509ConfigurationTests.java | 148 ++++++++++++++++++ .../CustomX509Configuration.java | 75 +++++++++ .../DefaultX509Configuration.java | 67 ++++++++ .../X509ConfigurationTests.java | 103 ++++++++++++ .../reactivex509/CustomX509Configuration.kt | 74 +++++++++ .../reactivex509/DefaultX509Configuration.kt | 64 ++++++++ .../reactivex509/X509ConfigurationTests.kt | 131 ++++++++++++++++ .../CustomX509Configuration.kt | 69 ++++++++ .../DefaultX509Configuration.kt | 64 ++++++++ .../X509ConfigurationTests.kt | 75 +++++++++ .../CustomX509Configuration.xml | 45 ++++++ .../DefaultX509Configuration.xml | 39 +++++ .../x509/SubjectDnX509PrincipalExtractor.java | 2 + .../x509/SubjectX500PrincipalExtractor.java | 137 ++++++++++++++++ .../x509/X509AuthenticationFilter.java | 2 +- .../SubjectDnX509PrincipalExtractorTests.java | 7 + .../SubjectX500PrincipalExtractorTests.java | 90 +++++++++++ .../preauth/x509/X509TestUtils.java | 84 ++++++++++ 33 files changed, 1662 insertions(+), 125 deletions(-) create mode 100644 config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRef.xml create mode 100644 config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml create mode 100644 docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/CustomX509Configuration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/DefaultX509Configuration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/X509ConfigurationTests.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/X509ConfigurationTests.java create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/CustomX509Configuration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/DefaultX509Configuration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/X509ConfigurationTests.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/CustomX509Configuration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/DefaultX509Configuration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/X509ConfigurationTests.kt create mode 100644 docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.xml create mode 100644 docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.xml create mode 100644 web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java create mode 100644 web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java index f0daef4837..2b7b354145 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java @@ -74,6 +74,7 @@ import org.springframework.security.web.context.RequestAttributeSecurityContextR * * @author Rob Winch * @author Ngoc Nhan + * @author Max Batischev * @since 3.2 */ public final class X509Configurer> @@ -161,7 +162,10 @@ public final class X509Configurer> * @param subjectPrincipalRegex the regex to extract the user principal from the * certificate (i.e. "CN=(.*?)(?:,|$)"). * @return the {@link X509Configurer} for further customizations + * @deprecated Please use {@link #x509PrincipalExtractor(X509PrincipalExtractor)} + * instead */ + @Deprecated public X509Configurer subjectPrincipalRegex(String subjectPrincipalRegex) { SubjectDnX509PrincipalExtractor principalExtractor = new SubjectDnX509PrincipalExtractor(); principalExtractor.setSubjectDnRegex(subjectPrincipalRegex); diff --git a/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java b/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java index b0df463c52..c131f3ca7e 100644 --- a/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java @@ -521,11 +521,23 @@ final class AuthenticationConfigBuilder { filterBuilder.addPropertyValue("authenticationManager", authManager); filterBuilder.addPropertyValue("securityContextHolderStrategy", authenticationFilterSecurityContextHolderStrategyRef); - String regex = x509Elt.getAttribute("subject-principal-regex"); - if (StringUtils.hasText(regex)) { + String principalExtractorRef = x509Elt.getAttribute("principal-extractor-ref"); + String subjectPrincipalRegex = x509Elt.getAttribute("subject-principal-regex"); + boolean hasPrincipalExtractorRef = StringUtils.hasText(principalExtractorRef); + boolean hasSubjectPrincipalRegex = StringUtils.hasText(subjectPrincipalRegex); + if (hasPrincipalExtractorRef && hasSubjectPrincipalRegex) { + this.pc.getReaderContext() + .error("The attribute 'principal-extractor-ref' cannot be used together with the 'subject-principal-regex' attribute within <" + + Elements.X509 + ">", this.pc.extractSource(x509Elt)); + } + if (hasPrincipalExtractorRef) { + RuntimeBeanReference principalExtractor = new RuntimeBeanReference(principalExtractorRef); + filterBuilder.addPropertyValue("principalExtractor", principalExtractor); + } + if (hasSubjectPrincipalRegex) { BeanDefinitionBuilder extractor = BeanDefinitionBuilder .rootBeanDefinition(SubjectDnX509PrincipalExtractor.class); - extractor.addPropertyValue("subjectDnRegex", regex); + extractor.addPropertyValue("subjectDnRegex", subjectPrincipalRegex); filterBuilder.addPropertyValue("principalExtractor", extractor.getBeanDefinition()); } injectAuthenticationDetailsSource(x509Elt, filterBuilder); 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 68ebdb037d..dc252b06c9 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 @@ -119,7 +119,7 @@ import org.springframework.security.oauth2.server.resource.web.server.BearerToke import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; import org.springframework.security.web.PortMapper; import org.springframework.security.web.authentication.logout.LogoutHandler; -import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint; @@ -943,8 +943,8 @@ public class ServerHttpSecurity { * } * * - * Note that if extractor is not specified, {@link SubjectDnX509PrincipalExtractor} - * will be used. If authenticationManager is not specified, + * Note that if extractor is not specified, {@link SubjectX500PrincipalExtractor} will + * be used. If authenticationManager is not specified, * {@link ReactivePreAuthenticatedAuthenticationManager} will be used. * @return the {@link X509Spec} to customize * @since 5.2 @@ -978,8 +978,8 @@ public class ServerHttpSecurity { * } * * - * Note that if extractor is not specified, {@link SubjectDnX509PrincipalExtractor} - * will be used. If authenticationManager is not specified, + * Note that if extractor is not specified, {@link SubjectX500PrincipalExtractor} will + * be used. If authenticationManager is not specified, * {@link ReactivePreAuthenticatedAuthenticationManager} will be used. * @param x509Customizer the {@link Customizer} to provide more options for the * {@link X509Spec} @@ -4180,7 +4180,7 @@ public class ServerHttpSecurity { if (this.principalExtractor != null) { return this.principalExtractor; } - return new SubjectDnX509PrincipalExtractor(); + return new SubjectX500PrincipalExtractor(); } private ReactiveAuthenticationManager getAuthenticationManager() { diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc index ec51246b6f..af4815d852 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.rnc @@ -1050,6 +1050,9 @@ x509.attlist &= x509.attlist &= ## Reference to an AuthenticationDetailsSource which will be used by the authentication filter attribute authentication-details-source-ref {xsd:token}? +x509.attlist &= + ## Reference to an X509PrincipalExtractor which will be used by the authentication filter + attribute principal-extractor-ref {xsd:token}? jee = ## Adds a J2eePreAuthenticatedProcessingFilter to the filter chain to provide integration with container authentication. diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd index e254b8488e..6c2e3bb1d0 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.5.xsd @@ -2911,6 +2911,12 @@ + + + Reference to an X509PrincipalExtractor which will be used by the authentication filter + + + diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java index edaec322d4..b3176dfbeb 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java @@ -43,7 +43,9 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; @@ -123,6 +125,28 @@ public class X509ConfigurerTests { // @formatter:on } + @Test + public void x509WhenSubjectX500PrincipalExtractor() throws Exception { + this.spring.register(SubjectX500PrincipalExtractorConfig.class).autowire(); + X509Certificate certificate = loadCert("rod.cer"); + // @formatter:off + this.mvc.perform(get("/").with(x509(certificate))) + .andExpect((result) -> assertThat(result.getRequest().getSession(false)).isNull()) + .andExpect(authenticated().withUsername("rod")); + // @formatter:on + } + + @Test + public void x509WhenSubjectX500PrincipalExtractorBean() throws Exception { + this.spring.register(SubjectX500PrincipalExtractorEmailConfig.class).autowire(); + X509Certificate certificate = X509TestUtils.buildTestCertificate(); + // @formatter:off + this.mvc.perform(get("/").with(x509(certificate))) + .andExpect((result) -> assertThat(result.getRequest().getSession(false)).isNull()) + .andExpect(authenticated().withUsername("luke@monkeymachine")); + // @formatter:on + } + @Test public void x509WhenUserDetailsServiceNotConfiguredThenUsesBean() throws Exception { this.spring.register(UserDetailsServiceBeanConfig.class).autowire(); @@ -277,6 +301,62 @@ public class X509ConfigurerTests { } + @Configuration + @EnableWebSecurity + static class SubjectX500PrincipalExtractorConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .x509((x509) -> x509 + .x509PrincipalExtractor(new SubjectX500PrincipalExtractor()) + ); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("rod") + .password("password") + .roles("USER", "ADMIN") + .build(); + return new InMemoryUserDetailsManager(user); + } + + } + + @Configuration + @EnableWebSecurity + static class SubjectX500PrincipalExtractorEmailConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + SubjectX500PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor(); + principalExtractor.setExtractPrincipalNameFromEmail(true); + // @formatter:off + http + .x509((x509) -> x509 + .x509PrincipalExtractor(principalExtractor) + ); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("luke@monkeymachine") + .password("password") + .roles("USER", "ADMIN") + .build(); + return new InMemoryUserDetailsManager(user); + } + + } + @Configuration @EnableWebSecurity static class UserDetailsServiceBeanConfig { diff --git a/config/src/test/java/org/springframework/security/config/http/MiscHttpConfigTests.java b/config/src/test/java/org/springframework/security/config/http/MiscHttpConfigTests.java index 782809bf05..4c60e6abdf 100644 --- a/config/src/test/java/org/springframework/security/config/http/MiscHttpConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/MiscHttpConfigTests.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.io.OutputStream; import java.security.AccessController; import java.security.Principal; +import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; @@ -91,6 +92,7 @@ import org.springframework.security.web.authentication.AnonymousAuthenticationFi import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultResourcesFilter; @@ -398,6 +400,27 @@ public class MiscHttpConfigTests { .containsSubsequence(CsrfFilter.class, X509AuthenticationFilter.class, ExceptionTranslationFilter.class); } + @Test + public void getWhenUsingX509PrincipalExtractorRef() throws Exception { + this.spring.configLocations(xml("X509PrincipalExtractorRef")).autowire(); + X509Certificate certificate = X509TestUtils.buildTestCertificate(); + RequestPostProcessor x509 = x509(certificate); + // @formatter:off + this.mvc.perform(get("/protected").with(x509)) + .andExpect(status().isOk()); + // @formatter:on + } + + @Test + public void getWhenUsingX509PrincipalExtractorRefAndSubjectPrincipalRegex() throws Exception { + String xmlResourceName = "X509PrincipalExtractorRefAndSubjectPrincipalRegex"; + // @formatter:off + assertThatExceptionOfType(BeanDefinitionParsingException.class) + .isThrownBy(() -> this.spring.configLocations(xml(xmlResourceName)).autowire()) + .withMessage("Configuration problem: The attribute 'principal-extractor-ref' cannot be used together with the 'subject-principal-regex' attribute within \n" + "Offending resource: class path resource [org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml]"); + // @formatter:on + } + @Test public void getWhenUsingX509AndPropertyPlaceholderThenSubjectPrincipalRegexIsConfigured() throws Exception { System.setProperty("subject_principal_regex", "OU=(.*?)(?:,|$)"); diff --git a/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRef.xml b/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRef.xml new file mode 100644 index 0000000000..51a3a1bbf1 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRef.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml b/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml new file mode 100644 index 0000000000..1fc7e0f12c --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/docs/antora.yml b/docs/antora.yml index 02262db72e..c6a145aaab 100644 --- a/docs/antora.yml +++ b/docs/antora.yml @@ -18,3 +18,4 @@ asciidoc: gh-url: "https://github.com/spring-projects/spring-security/tree/{gh-tag}" include-java: 'example$docs-src/test/java/org/springframework/security/docs' include-kotlin: 'example$docs-src/test/kotlin/org/springframework/security/kt/docs' + include-xml: 'example$docs-src/test/resources/org/springframework/security/docs' diff --git a/docs/modules/ROOT/pages/reactive/authentication/x509.adoc b/docs/modules/ROOT/pages/reactive/authentication/x509.adoc index a077dd5c65..947084423c 100644 --- a/docs/modules/ROOT/pages/reactive/authentication/x509.adoc +++ b/docs/modules/ROOT/pages/reactive/authentication/x509.adoc @@ -5,98 +5,16 @@ Similar to xref:servlet/authentication/x509.adoc#servlet-x509[Servlet X.509 auth The following example shows a reactive x509 security configuration: -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -@Bean -public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { - http - .x509(withDefaults()) - .authorizeExchange(exchanges -> exchanges - .anyExchange().permitAll() - ); - return http.build(); -} ----- +include-code::./DefaultX509Configuration[tag=springSecurity,indent=0] -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -@Bean -fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - x509 { } - authorizeExchange { - authorize(anyExchange, authenticated) - } - } -} ----- -====== - -In the preceding configuration, when neither `principalExtractor` nor `authenticationManager` is provided, defaults are used. The default principal extractor is `SubjectDnX509PrincipalExtractor`, which extracts the CN (common name) field from a certificate provided by a client. The default authentication manager is `ReactivePreAuthenticatedAuthenticationManager`, which performs user account validation, checking that a user account with a name extracted by `principalExtractor` exists and that it is not locked, disabled, or expired. +In the preceding configuration, when neither `principalExtractor` nor `authenticationManager` is provided, defaults are used. +The default principal extractor is `SubjectX500PrincipalExtractor`, which extracts the CN (common name) field from a certificate provided by a client. +The default authentication manager is `ReactivePreAuthenticatedAuthenticationManager`, which performs user account validation, checking that a user account with a name extracted by `principalExtractor` exists and that it is not locked, disabled, or expired. The following example demonstrates how these defaults can be overridden: -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -@Bean -public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { - SubjectDnX509PrincipalExtractor principalExtractor = - new SubjectDnX509PrincipalExtractor(); +include-code::./CustomX509Configuration[tag=springSecurity,indent=0] - principalExtractor.setSubjectDnRegex("OU=(.*?)(?:,|$)"); - - ReactiveAuthenticationManager authenticationManager = authentication -> { - authentication.setAuthenticated("Trusted Org Unit".equals(authentication.getName())); - return Mono.just(authentication); - }; - - http - .x509(x509 -> x509 - .principalExtractor(principalExtractor) - .authenticationManager(authenticationManager) - ) - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ); - return http.build(); -} ----- - -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -@Bean -fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { - val customPrincipalExtractor = SubjectDnX509PrincipalExtractor() - customPrincipalExtractor.setSubjectDnRegex("OU=(.*?)(?:,|$)") - val customAuthenticationManager = ReactiveAuthenticationManager { authentication: Authentication -> - authentication.isAuthenticated = "Trusted Org Unit" == authentication.name - Mono.just(authentication) - } - return http { - x509 { - principalExtractor = customPrincipalExtractor - authenticationManager = customAuthenticationManager - } - authorizeExchange { - authorize(anyExchange, authenticated) - } - } -} ----- -====== - -In the previous example, a username is extracted from the OU field of a client certificate instead of CN, and account lookup using `ReactiveUserDetailsService` is not performed at all. Instead, if the provided certificate issued to an OU named "`Trusted Org Unit`", a request is authenticated. +In the previous example, a username is extracted from the `emailAddress` field of a client certificate instead of CN, and account lookup uses a custom `ReactiveAuthenticationManager` instance. For an example of configuring Netty and `WebClient` or `curl` command-line tool to use mutual TLS and enable X.509 authentication, see https://github.com/spring-projects/spring-security-samples/tree/main/servlet/java-configuration/authentication/x509. diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index 2b434d4303..70398bb934 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -2212,6 +2212,10 @@ A `PreAuthenticatedAuthenticationProvider` will also be created which delegates * **authentication-details-source-ref** A reference to an `AuthenticationDetailsSource` +[[nsa-x509-principal-extractor-ref]] +* **principal-extractor-ref** +Reference to an `X509PrincipalExtractor` which will be used by the authentication filter. + [[nsa-x509-subject-principal-regex]] * **subject-principal-regex** @@ -2223,7 +2227,6 @@ Defines a regular expression which will be used to extract the username from the Allows a specific `UserDetailsService` to be used with X.509 in the case where multiple instances are configured. If not set, an attempt will be made to locate a suitable instance automatically and use that. - [[nsa-filter-chain-map]] == Used to explicitly configure a FilterChainProxy instance with a FilterChainMap diff --git a/docs/modules/ROOT/pages/servlet/authentication/x509.adoc b/docs/modules/ROOT/pages/servlet/authentication/x509.adoc index 26dbb1a900..2d670a632e 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/x509.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/x509.adoc @@ -14,37 +14,27 @@ You should get this working before trying it out with Spring Security. The Spring Security X.509 module extracts the certificate by using a filter. It maps the certificate to an application user and loads that user's set of granted authorities for use with the standard Spring Security infrastructure. - +[[servlet-x509-config]] == Adding X.509 Authentication to Your Web Application -Enabling X.509 client authentication is very straightforward. -To do so, add the `` element to your http security namespace configuration: -[source,xml] ----- - -... - ; - ----- +Similar to xref:reactive/authentication/x509.adoc[Reactive X.509 authentication], the servlet x509 authentication filter allows extracting an authentication token from a certificate provided by a client. -The element has two optional attributes: +The following example shows a reactive x509 security configuration: -* `subject-principal-regex`. -The regular expression used to extract a username from the certificate's subject name. -The default value is shown in the preceding listing. -This is the username that is passed to the `UserDetailsService` to load the authorities for the user. -* `user-service-ref`. -This is the bean ID of the `UserDetailsService` to be used with X.509. -It is not needed if there is only one defined in your application context. +include-code::./DefaultX509Configuration[tag=springSecurity,indent=0] + +In the preceding configuration, when neither `principalExtractor` nor `authenticationManager` is provided, defaults are used. +The default principal extractor is `SubjectX500PrincipalExtractor`, which extracts the CN (common name) field from a certificate provided by a client. +The default authentication manager is `ReactivePreAuthenticatedAuthenticationManager`, which performs user account validation, checking that a user account with a name extracted by `principalExtractor` exists and that it is not locked, disabled, or expired. + +The following example demonstrates how these defaults can be overridden: + +include-code::./CustomX509Configuration[tag=springSecurity,indent=0] + +In the previous example, a username is extracted from the `emailAddress` field of a client certificate instead of CN, and account lookup uses a custom `ReactiveAuthenticationManager` instance. + +For an example of configuring Netty and `WebClient` or `curl` command-line tool to use mutual TLS and enable X.509 authentication, see https://github.com/spring-projects/spring-security-samples/tree/main/servlet/java-configuration/authentication/x509. -The `subject-principal-regex` should contain a single group. -For example, the default expression (`CN=(.*?)`) matches the common name field. -So, if the subject name in the certificate is "CN=Jimi Hendrix, OU=...", this gives a user name of "Jimi Hendrix". -The matches are case insensitive. -So "emailAddress=(+.*?+)," matches "EMAILADDRESS=jimi@hendrix.org,CN=...", giving a user name "jimi@hendrix.org". -If the client presents a certificate and a valid username is successfully extracted, there should be a valid `Authentication` object in the security context. -If no certificate is found or no corresponding user could be found, the security context remains empty. -This means that you can use X.509 authentication with other options, such as a form-based login. [[x509-ssl-config]] == Setting up SSL in Tomcat diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/CustomX509Configuration.java b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/CustomX509Configuration.java new file mode 100644 index 0000000000..8f7f8c8278 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/CustomX509Configuration.java @@ -0,0 +1,74 @@ +/* + * 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.docs.reactive.authentication.reactivex509; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.ReactivePreAuthenticatedAuthenticationManager; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +@EnableWebFlux +public class CustomX509Configuration { + + // tag::springSecurity[] + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + SubjectX500PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor(); + principalExtractor.setExtractPrincipalNameFromEmail(true); + + // @formatter:off + UserDetails user = User + .withUsername("luke@monkeymachine") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + ReactiveUserDetailsService users = new MapReactiveUserDetailsService(user); + ReactiveAuthenticationManager authenticationManager = new ReactivePreAuthenticatedAuthenticationManager(users); + + // @formatter:off + http + .x509(x509 -> x509 + .principalExtractor(principalExtractor) + .authenticationManager(authenticationManager) + ) + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ); + // @formatter:on + return http.build(); + } + // end::springSecurity[] + +} diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/DefaultX509Configuration.java b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/DefaultX509Configuration.java new file mode 100644 index 0000000000..0fd17fa1d8 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/DefaultX509Configuration.java @@ -0,0 +1,67 @@ +/* + * 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.docs.reactive.authentication.reactivex509; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +@EnableWebFlux +public class DefaultX509Configuration { + + // tag::springSecurity[] + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + // @formatter:off + http + .x509(Customizer.withDefaults()) + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ); + // @formatter:on + return http.build(); + } + // end::springSecurity[] + + @Bean + ReactiveUserDetailsService userDetailsService() { + // @formatter:off + UserDetails user = User + .withUsername("rod") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new MapReactiveUserDetailsService(user); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/X509ConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/X509ConfigurationTests.java new file mode 100644 index 0000000000..475787da4f --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/X509ConfigurationTests.java @@ -0,0 +1,148 @@ +/* + * 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.docs.reactive.authentication.reactivex509; + +import java.io.InputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.SslInfo; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClientConfigurer; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.x509; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension.class) +public class X509ConfigurationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + WebTestClient client; + + @Autowired + void setSpringSecurityFilterChain(WebFilter springSecurityFilterChain) { + this.client = WebTestClient.bindToController(WebTestClientBuilder.Http200RestController.class) + .webFilter(springSecurityFilterChain) + .apply(springSecurity()) + .configureClient() + .build(); + } + + @Test + void x509WhenDefaultX509Configuration() throws Exception { + this.spring.register(DefaultX509Configuration.class).autowire(); + X509Certificate certificate = loadCert("rod.cer"); + // @formatter:off + this.client + .mutateWith(x509(certificate)) + .get() + .uri("/") + .exchange() + .expectStatus().isOk(); + // @formatter:on + } + + @Test + void x509WhenCustomX509Configuration() throws Exception { + this.spring.register(CustomX509Configuration.class).autowire(); + X509Certificate certificate = X509TestUtils.buildTestCertificate(); + // @formatter:off + this.client + .mutateWith(x509(certificate)) + .get() + .uri("/") + .exchange() + .expectStatus().isOk(); + // @formatter:on + } + + private static @NotNull WebTestClientConfigurer x509(X509Certificate certificate) { + return (builder, httpHandlerBuilder, connector) -> { + builder.apply(new WebTestClientConfigurer() { + @Override + public void afterConfigurerAdded(WebTestClient.Builder builder, + @Nullable WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable ClientHttpConnector connector) { + SslInfo sslInfo = new SslInfo() { + @Override + public @Nullable String getSessionId() { + return "sessionId"; + } + + @Override + public X509Certificate @Nullable [] getPeerCertificates() { + return new X509Certificate[] { certificate }; + } + }; + httpHandlerBuilder.filters((filters) -> filters.add(0, new SslInfoOverrideWebFilter(sslInfo))); + } + }); + }; + } + + private static class SslInfoOverrideWebFilter implements WebFilter { + private final SslInfo sslInfo; + + private SslInfoOverrideWebFilter(SslInfo sslInfo) { + this.sslInfo = sslInfo; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + ServerHttpRequest sslInfoRequest = exchange.getRequest().mutate().sslInfo(sslInfo) + .build(); + ServerWebExchange sslInfoExchange = exchange.mutate().request(sslInfoRequest).build(); + return chain.filter(sslInfoExchange); + } + } + + private T loadCert(String location) { + try (InputStream is = new ClassPathResource(location).getInputStream()) { + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + return (T) certFactory.generateCertificate(is); + } + catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.java new file mode 100644 index 0000000000..70b46b7c54 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.java @@ -0,0 +1,75 @@ +/* + * 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.docs.servlet.authentication.servletx509config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebMvc +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class CustomX509Configuration { + + // tag::springSecurity[] + @Bean + DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception { + SubjectX500PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor(); + principalExtractor.setExtractPrincipalNameFromEmail(true); + + + // @formatter:off + http + .x509((x509) -> x509 + .x509PrincipalExtractor(principalExtractor) + ) + .authorizeHttpRequests((exchanges) -> exchanges + .anyRequest().authenticated() + ); + // @formatter:on + return http.build(); + } + // end::springSecurity[] + + @Bean + UserDetailsService userDetailsService() { + // @formatter:off + UserDetails user = User + .withUsername("luke@monkeymachine") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.java new file mode 100644 index 0000000000..a347235386 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.java @@ -0,0 +1,67 @@ +/* + * 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.docs.servlet.authentication.servletx509config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebMvc +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class DefaultX509Configuration { + + // tag::springSecurity[] + @Bean + DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception { + // @formatter:off + http + .x509(Customizer.withDefaults()) + .authorizeHttpRequests(exchanges -> exchanges + .anyRequest().authenticated() + ); + // @formatter:on + return http.build(); + } + // end::springSecurity[] + + @Bean + UserDetailsService userDetailsService() { + // @formatter:off + UserDetails user = User + .withUsername("rod") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/X509ConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/X509ConfigurationTests.java new file mode 100644 index 0000000000..c6aa6f6d41 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/X509ConfigurationTests.java @@ -0,0 +1,103 @@ +/* + * 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.docs.servlet.authentication.servletx509config; + +import java.io.InputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.SslInfo; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClientConfigurer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.x509; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension.class) +public class X509ConfigurationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + MockMvc mockMvc; + + @Test + void x509WhenDefaultX509Configuration() throws Exception { + this.spring.register(DefaultX509Configuration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/").with(x509("rod.cer"))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("rod")); + // @formatter:on + } + + @Test + void x509WhenDefaultX509ConfigurationXml() throws Exception { + this.spring.testConfigLocations("DefaultX509Configuration.xml").autowire(); + // @formatter:off + this.mockMvc.perform(get("/").with(x509("rod.cer"))) + .andExpect(authenticated().withUsername("rod")); + // @formatter:on + } + + @Test + void x509WhenCustomX509Configuration() throws Exception { + this.spring.register(CustomX509Configuration.class, Http200Controller.class).autowire(); + X509Certificate certificate = X509TestUtils.buildTestCertificate(); + // @formatter:off + this.mockMvc.perform(get("/").with(x509(certificate))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("luke@monkeymachine")); + // @formatter:on + } + + @RestController + static class Http200Controller { + @GetMapping("/**") + String ok() { + return "ok"; + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/CustomX509Configuration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/CustomX509Configuration.kt new file mode 100644 index 0000000000..78c4fbe34b --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/CustomX509Configuration.kt @@ -0,0 +1,74 @@ +/* + * 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.kt.docs.reactive.authentication.reactivex509 + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authentication.ReactiveAuthenticationManager +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.invoke +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity.http +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService +import org.springframework.security.core.userdetails.ReactiveUserDetailsService +import org.springframework.security.core.userdetails.User +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.authentication.ReactivePreAuthenticatedAuthenticationManager +import org.springframework.web.reactive.config.EnableWebFlux + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebFlux +@EnableWebFluxSecurity +@Configuration(proxyBeanMethods = false) +class CustomX509Configuration { + + // tag::springSecurity[] + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + val extractor = SubjectX500PrincipalExtractor() + extractor.setExtractPrincipalNameFromEmail(true) + + // @formatter:off + val user = User + .withUsername("luke@monkeymachine") + .password("password") + .roles("USER") + .build() + // @formatter:on + + val users: ReactiveUserDetailsService = MapReactiveUserDetailsService(user) + val authentication: ReactiveAuthenticationManager = ReactivePreAuthenticatedAuthenticationManager(users) + + return http { + x509 { + principalExtractor = extractor + authenticationManager = authentication + } + authorizeExchange { + authorize(anyExchange, authenticated) + } + } + } + // end::springSecurity[] + + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/DefaultX509Configuration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/DefaultX509Configuration.kt new file mode 100644 index 0000000000..61e5d65db5 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/DefaultX509Configuration.kt @@ -0,0 +1,64 @@ +/* + * 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.kt.docs.reactive.authentication.reactivex509 + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.invoke +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService +import org.springframework.security.core.userdetails.User +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.web.reactive.config.EnableWebFlux + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebFlux +@EnableWebFluxSecurity +@Configuration(proxyBeanMethods = false) +class DefaultX509Configuration { + + // tag::springSecurity[] + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + x509 { } + authorizeExchange { + authorize(anyExchange, authenticated) + } + } + } + // end::springSecurity[] + + @Bean + fun userDetailsService(): MapReactiveUserDetailsService { + // @formatter:off + val user = User + .withUsername("rod") + .password("password") + .roles("USER") + .build() + // @formatter:on + + return MapReactiveUserDetailsService(user) + } + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/X509ConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/X509ConfigurationTests.kt new file mode 100644 index 0000000000..f41763ea9a --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/X509ConfigurationTests.kt @@ -0,0 +1,131 @@ +/* + * 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.kt.docs.reactive.authentication.reactivex509 + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.ClassPathResource +import org.springframework.http.client.reactive.ClientHttpConnector +import org.springframework.http.server.reactive.SslInfo +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder.Http200RestController +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.WebTestClientConfigurer +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import org.springframework.web.server.adapter.WebHttpHandlerBuilder +import reactor.core.publisher.Mono +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.function.Consumer + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension::class) +class X509ConfigurationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + var client: WebTestClient? = null + + @Autowired + fun setSpringSecurityFilterChain(springSecurityFilterChain: WebFilter) { + this.client = WebTestClient + .bindToController(Http200RestController::class.java) + .webFilter(springSecurityFilterChain) + .apply(SecurityMockServerConfigurers.springSecurity()) + .configureClient() + .build() + } + + @Test + fun x509WhenDefaultX509Configuration() { + this.spring.register(DefaultX509Configuration::class.java).autowire() + val certificate = loadCert("rod.cer") + // @formatter:off + this.client!!.mutateWith(x509(certificate)) + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + // @formatter:on + } + + @Test + fun x509WhenCustomX509Configuration() { + this.spring.register(CustomX509Configuration::class.java).autowire() + val certificate = X509TestUtils.buildTestCertificate() + // @formatter:off + this.client!!.mutateWith(x509(certificate)) + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + // @formatter:on + } + + private class SslInfoOverrideWebFilter(private val sslInfo: SslInfo) : WebFilter { + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + val sslInfoRequest = exchange.getRequest().mutate().sslInfo(sslInfo) + .build() + val sslInfoExchange = exchange.mutate().request(sslInfoRequest).build() + return chain.filter(sslInfoExchange) + } + } + + private fun loadCert(location: String): T { + try { + ClassPathResource(location).getInputStream().use { `is` -> + val certFactory = CertificateFactory.getInstance("X.509") + return certFactory.generateCertificate(`is`) as T + } + } catch (ex: Exception) { + throw IllegalArgumentException(ex) + } + } + + companion object { + private fun x509(certificate: X509Certificate): WebTestClientConfigurer { + return WebTestClientConfigurer { builder: WebTestClient.Builder, httpHandlerBuilder: WebHttpHandlerBuilder?, connector: ClientHttpConnector? -> + + val sslInfo: SslInfo = object : SslInfo { + override fun getSessionId(): String { + return "sessionId" + } + + override fun getPeerCertificates(): Array { + return arrayOf(certificate) + } + } + httpHandlerBuilder?.filters(Consumer { filters: MutableList -> + filters.add( + 0, + SslInfoOverrideWebFilter(sslInfo) + ) + }) + } + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/CustomX509Configuration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/CustomX509Configuration.kt new file mode 100644 index 0000000000..b079ce26fb --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/CustomX509Configuration.kt @@ -0,0 +1,69 @@ +/* + * 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.kt.docs.servlet.authentication.servlet509config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor +import org.springframework.web.servlet.config.annotation.EnableWebMvc + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebMvc +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class CustomX509Configuration { + // tag::springSecurity[] + @Bean + fun springSecurity(http: HttpSecurity): DefaultSecurityFilterChain? { + val principalExtractor = SubjectX500PrincipalExtractor() + principalExtractor.setExtractPrincipalNameFromEmail(true) + + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + x509 { + x509PrincipalExtractor = principalExtractor + } + } + return http.build() + } + // end::springSecurity[] + + @Bean + fun userDetailsService(): UserDetailsService { + // @formatter:off + val user = User + .withUsername("luke@monkeymachine") + .password("password") + .roles("USER") + .build() + // @formatter:on + return InMemoryUserDetailsManager(user) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/DefaultX509Configuration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/DefaultX509Configuration.kt new file mode 100644 index 0000000000..3c777ebe32 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/DefaultX509Configuration.kt @@ -0,0 +1,64 @@ +/* + * 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.kt.docs.servlet.authentication.servlet509config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.web.servlet.config.annotation.EnableWebMvc + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebMvc +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class DefaultX509Configuration { + // tag::springSecurity[] + @Bean + fun springSecurity(http: HttpSecurity): DefaultSecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + x509 { } + } + // @formatter:on + return http.build() + } + // end::springSecurity[] + + @Bean + fun userDetailsService(): UserDetailsService { + // @formatter:off + val user = User + .withUsername("rod") + .password("password") + .roles("USER") + .build() + // @formatter:on + return InMemoryUserDetailsManager(user) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/X509ConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/X509ConfigurationTests.kt new file mode 100644 index 0000000000..edaf503039 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/X509ConfigurationTests.kt @@ -0,0 +1,75 @@ +/* + * 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.kt.docs.servlet.authentication.servlet509config + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension::class) +class X509ConfigurationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + @Autowired + var mockMvc: MockMvc? = null + + @Test + @Throws(Exception::class) + fun x509WhenDefaultX509Configuration() { + this.spring.register(DefaultX509Configuration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("rod")) + // @formatter:on + } + + @Test + @Throws(Exception::class) + fun x509WhenCustomX509Configuration() { + this.spring.register(CustomX509Configuration::class.java, Http200Controller::class.java).autowire() + val certificate = X509TestUtils.buildTestCertificate() + // @formatter:off + this.mockMvc!!.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509(certificate))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("luke@monkeymachine")) + // @formatter:on + } + + @RestController + internal class Http200Controller { + @GetMapping("/**") + fun ok(): String { + return "ok" + } + } +} diff --git a/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.xml b/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.xml new file mode 100644 index 0000000000..cc2772e34b --- /dev/null +++ b/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.xml b/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.xml new file mode 100644 index 0000000000..62621d1cab --- /dev/null +++ b/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java index ce5ff1377d..ce45091fa5 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java @@ -43,7 +43,9 @@ import org.springframework.util.Assert; * "EMAILADDRESS=jimi@hendrix.org, CN=..." giving a user name "jimi@hendrix.org" * * @author Luke Taylor + * @deprecated Please use {@link SubjectX500PrincipalExtractor} instead */ +@Deprecated public class SubjectDnX509PrincipalExtractor implements X509PrincipalExtractor, MessageSourceAware { protected final Log logger = LogFactory.getLog(getClass()); diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java new file mode 100644 index 0000000000..e377a47199 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java @@ -0,0 +1,137 @@ +/* + * Copyright 2004-present 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.authentication.preauth.x509; + +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.security.auth.x500.X500Principal; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceAware; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.core.log.LogMessage; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.SpringSecurityMessageSource; +import org.springframework.util.Assert; + +/** + * Extracts the principal from the {@link X500Principal#getName(String)} returned by + * {@link X509Certificate#getSubjectX500Principal()} passed into + * {@link #extractPrincipal(X509Certificate)} depending on the value of + * {@link #setExtractPrincipalNameFromEmail(boolean)}. + * + * @author Max Batischev + * @author Rob Winch + * @since 6.5.11 + */ +public final class SubjectX500PrincipalExtractor implements X509PrincipalExtractor, MessageSourceAware { + + private final Log logger = LogFactory.getLog(getClass()); + + private static final String EMAIL_SUBJECT_DN_TYPE = "OID.1.2.840.113549.1.9.1"; + + private static final String CN_SUBJECT_DN_TYPE = "CN"; + + private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); + + private String subjectDnType = CN_SUBJECT_DN_TYPE; + + private String x500PrincipalFormat = X500Principal.RFC2253; + + @Override + public Object extractPrincipal(X509Certificate clientCert) { + Assert.notNull(clientCert, "clientCert cannot be null"); + X500Principal principal = clientCert.getSubjectX500Principal(); + String subjectDN = principal.getName(this.x500PrincipalFormat); + this.logger.debug(LogMessage.format("Subject DN is '%s'", subjectDN)); + String principalName = getSubject(subjectDN); + this.logger.debug(LogMessage.format("Extracted Principal name is '%s'", principalName)); + return principalName; + } + + private List getDns(String subjectDn) { + try { + // read most-specific first, see gh-19254 + List rdns = new ArrayList<>(new LdapName(subjectDn).getRdns()); + Collections.reverse(rdns); + return rdns; + } + catch (InvalidNameException ex) { + throw new BadCredentialsException("Failed to parse client certificate", ex); + } + } + + private String getSubject(String subjectDn) { + for (Rdn rdn : getDns(subjectDn)) { + String type = rdn.getType(); + if (this.subjectDnType.equals(type)) { + return String.valueOf(rdn.getValue()); + } + } + throw new BadCredentialsException(this.messages.getMessage("SubjectX500PrincipalExtractor.noMatching", + new Object[] { subjectDn }, "No matching pattern was found in subject DN: {0}")); + } + + @Override + public void setMessageSource(MessageSource messageSource) { + Assert.notNull(messageSource, "messageSource cannot be null"); + this.messages = new MessageSourceAccessor(messageSource); + } + + /** + * Sets if the principal name should be extracted from the emailAddress or CN + * attribute (default). + * + * By default, the format {@link X500Principal#RFC2253} is passed to + * {@link X500Principal#getName(String)} and the principal is extracted from the CN + * attribute as defined in + * Converting + * AttributeTypeAndValue of RFC2253. + * + * If {@link #setExtractPrincipalNameFromEmail(boolean)} is {@code true}, then the + * format {@link X500Principal#RFC2253} is passed to + * {@link X500Principal#getName(String)} and the principal is extracted from the + * OID.1.2.840.113549.1.9.1 + * (emailAddress) attribute as defined in + * Section 2.3 of + * RFC1779. + * @param extractPrincipalNameFromEmail whether to extract the principal from the + * emailAddress (default false) + * @see RFC2253 + * @see RFC1779 + */ + public void setExtractPrincipalNameFromEmail(boolean extractPrincipalNameFromEmail) { + if (extractPrincipalNameFromEmail) { + this.subjectDnType = EMAIL_SUBJECT_DN_TYPE; + this.x500PrincipalFormat = X500Principal.RFC1779; + } + else { + this.subjectDnType = CN_SUBJECT_DN_TYPE; + this.x500PrincipalFormat = X500Principal.RFC2253; + } + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/X509AuthenticationFilter.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/X509AuthenticationFilter.java index 3ec19de819..40ae9608b5 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/X509AuthenticationFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/X509AuthenticationFilter.java @@ -28,7 +28,7 @@ import org.springframework.security.web.authentication.preauth.AbstractPreAuthen */ public class X509AuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter { - private X509PrincipalExtractor principalExtractor = new SubjectDnX509PrincipalExtractor(); + private X509PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor(); @Override protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) { diff --git a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractorTests.java b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractorTests.java index 1dbdc3d9f1..c4a60f1588 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractorTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractorTests.java @@ -74,6 +74,13 @@ public class SubjectDnX509PrincipalExtractorTests { assertThat(principal).isEqualTo("Duke"); } + // gh-19254 + @Test + public void defaultCNPatternReturnsMostSpecificPrincipalWhenMultipleCns() throws Exception { + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertificateWithMultipleCns()); + assertThat(principal).isEqualTo("alice"); + } + @Test public void setMessageSourceWhenNullThenThrowsException() { assertThatIllegalArgumentException().isThrownBy(() -> this.extractor.setMessageSource(null)); diff --git a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java new file mode 100644 index 0000000000..5c53202290 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2004-present 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.authentication.preauth.x509; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link SubjectX500PrincipalExtractor}. + * + * @author Max Batischev + */ +public class SubjectX500PrincipalExtractorTests { + + private final SubjectX500PrincipalExtractor extractor = new SubjectX500PrincipalExtractor(); + + @Test + void extractWhenCnPatternSetThenExtractsPrincipalName() throws Exception { + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertificate()); + + assertThat(principal).isEqualTo("Luke Taylor"); + } + + @Test + void extractWhenEmailPatternSetThenExtractsPrincipalName() throws Exception { + this.extractor.setExtractPrincipalNameFromEmail(true); + + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertificate()); + + assertThat(principal).isEqualTo("luke@monkeymachine"); + } + + @Test + void extractWhenCnAtEndThenExtractsPrincipalName() throws Exception { + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertificateWithCnAtEnd()); + + assertThat(principal).isEqualTo("Duke"); + } + + @Test + void extractWhenDnEmbeddedInCnThenExtractsPrincipalName() throws Exception { + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertficateWithEmbeddedDn()); + + assertThat(principal).isEqualTo("luke"); + } + + // gh-19254 + @Test + void extractWhenMultipleCnsThenExtractsMostSpecificCn() throws Exception { + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertificateWithMultipleCns()); + + assertThat(principal).isEqualTo("alice"); + } + + @Test + void extractWhenEmailDnEmbeddedInCnThenExtractsEmail() throws Exception { + this.extractor.setExtractPrincipalNameFromEmail(true); + + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertficateWithEmbeddedEmailDn()); + + assertThat(principal).isEqualTo("luke@monkeymachine"); + } + + @Test + void setMessageSourceWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.extractor.setMessageSource(null)); + } + + @Test + void extractWhenCertificateIsNullThenFails() { + assertThatIllegalArgumentException().isThrownBy(() -> this.extractor.extractPrincipal(null)); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/X509TestUtils.java b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/X509TestUtils.java index 3c9c844424..d870ac8fb9 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/X509TestUtils.java +++ b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/X509TestUtils.java @@ -135,4 +135,88 @@ public final class X509TestUtils { return (X509Certificate) cf.generateCertificate(in); } + public static X509Certificate buildTestCertficateWithEmbeddedDn() throws Exception { + String cert = "-----BEGIN CERTIFICATE-----\n" + + "MIIDDTCCAfWgAwIBAgIJANSyvk4gJhqPMA0GCSqGSIb3DQEBCwUAMEYxDTALBgNV\n" + + "BAMMBGx1a2UxETAPBgNVBAsMCENOPWR1a2UsMRUwEwYDVQQKDAxFeGFtcGxlIENv\n" + + "cnAxCzAJBgNVBAYTAlVTMB4XDTI2MDEwNDE5MjY0N1oXDTI3MDEwNTE5MjY0N1ow\n" + + "RjENMAsGA1UEAwwEbHVrZTERMA8GA1UECwwIQ049ZHVrZSwxFTATBgNVBAoMDEV4\n" + + "YW1wbGUgQ29ycDELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\n" + + "ggEKAoIBAQDU9fY74nEFbBKfIef7CK02J/BJb42sIF9kD8eHN5OvEwLQBeTh30it\n" + + "E7LLalXyOXeUFkPe1N1ZhGdVak9udsIqULSvQaWqTbN+IrAGklZAxuXYTC1GbhMF\n" + + "AkGWWM55J2SNqVGQaHzZUn6VPxWaDft6nZR0DxuvXMYM5kVG6VErdB3ygGUv8cjQ\n" + + "QBKAYpsZeRldnauRPt2dImmGTagvSuJVyr8X/AioE2Rl0guii456AKw+QSvRiZ+g\n" + + "w08Y8C9nDyzQmurqpdYYkp0X+4yqm1iVowMX+tSPvHnlqJdvVzaW2b0yRzrrT6ao\n" + + "UCgw25slR1P1IcyzqPKWQIoQRnYIaX1bAgMBAAEwDQYJKoZIhvcNAQELBQADggEB\n" + + "AIos+nr8DFM6bAt9AI/79O/12hcN7gVv4F3P4Vz6NRRkkvsb9WMN8fLLDEsEJ/BQ\n" + + "eQkAVnhlmAe++vrqy8OTHoQ7F5C3K0zrr19NLNoyNFTkXkFgnm4ZhYinSbusuIb7\n" + + "LPYoyCnEEiMdl0VMWWSWcOvZpipbvTtH3CiVxTqXLjFFNraEAyUN50kXjo/zuHpK\n" + + "HzTS1BAu0li9GdV3Da2ELdDx90zaUym7dDIejY4YUlXYIJ5UUYS61fqtgOHGLLdb\n" + + "UXGAr5gqEe7OrQ9D4ebg9w5ciTb7g1H2CmirjTf/rkii8AojmsGFKIfGVe3gY6EB\n" + "o9eF5FV9V9leo5yLo25ev08=\n" + + "-----END CERTIFICATE-----"; + ByteArrayInputStream in = new ByteArrayInputStream(cert.getBytes()); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(in); + } + + public static X509Certificate buildTestCertficateWithEmbeddedEmailDn() throws Exception { + String cert = "-----BEGIN CERTIFICATE-----\n" + + "MIIDfDCCAmSgAwIBAgIIXHoOUFeZ29MwDQYJKoZIhvcNAQELBQAwfjEhMB8GCSqG\n" + + "SIb3DQEJARYSbHVrZUBtb25rZXltYWNoaW5lMTUwMwYDVQQLDCxPSUQuMS4yLjg0\n" + + "MC4xMTM1NDkuMS45LjE9ZHVrZUBnb3JpbGxhZ2FkZ2V0LDEVMBMGA1UECgwMRXhh\n" + + "bXBsZSBDb3JwMQswCQYDVQQGEwJVUzAeFw0yNjAxMDQxOTMxMDhaFw0yNzAxMDUx\n" + + "OTMxMDhaMH4xITAfBgkqhkiG9w0BCQEWEmx1a2VAbW9ua2V5bWFjaGluZTE1MDMG\n" + + "A1UECwwsT0lELjEuMi44NDAuMTEzNTQ5LjEuOS4xPWR1a2VAZ29yaWxsYWdhZGdl\n" + + "dCwxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDELMAkGA1UEBhMCVVMwggEiMA0GCSqG\n" + + "SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBuIQWnj+uvvG+4ZIyFMs4dSbiBavubCmC\n" + + "hudrHr93hP19QbPulbHRTVCUqEi8efvq+J9jmMdPd7tziuDX02PeG9uljp9+c5Ir\n" + + "pw9/oMoTRkF7K4PK1JxLN4tcgxjxVA4QkS+MjKLPeHrYyGCjKspcHbi+zBiQ9Xqp\n" + + "yHWq6N5XPd6mEj2gh0zamnsJCeUCOX4SJbcp3MFtcYzhguHAeVhy9Jv+EAMJejDn\n" + + "YIZmMUdP6Ykf2zTzs/4L3bRZb0oS5WvfeRdJB6SKg8mNO/jdGX87krSio//cRdDy\n" + + "TGQK+YCVDf8GyLLavYZW56AJbZxL3MWgHYilQjj4p+Kw/PWpaBVvAgMBAAEwDQYJ\n" + + "KoZIhvcNAQELBQADggEBAKVTMIo8JO0H0HRrpsEDP17E2pnfMJV4g70BwClUMMek\n" + + "wNIWZn+6XPR8oObzzjnVWXjrovMkmmyFk0vWIpF68MPyiQ++5fwdzOZiQtUP177n\n" + + "9ulAtLoIJld3olGeL9VsCZGp3J2PqiDe613zd+bkSUG1lQYC2awozWqJEdvwJJtf\n" + + "j9nlhyMsARKEEu3tFGJsCHST3XhbhFKOraf/GZ21xW650R7ap0ZNaEiB16M2a5Oe\n" + + "WXasgUukIo82Z8+yK4IITeCcr0aA1fJxwhU8J6qfYWloaoirSYj487HRnPPv3X/b\n" + + "RxZynIjtGKygT6T1dRaWennmoitqfprJnEO2tlhLwP0=\n" + "-----END CERTIFICATE-----"; + ByteArrayInputStream in = new ByteArrayInputStream(cert.getBytes()); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(in); + } + + /** + * Builds an X.509 certificate whose subject contains more than one CN. The subject DN + * is: + * + *
+	 *  CN=alice, CN=bob, O=Example Corp, C=US
+	 * 
+ * + * where {@code CN=alice} is the most specific (left-most) RDN. + */ + public static X509Certificate buildTestCertificateWithMultipleCns() throws Exception { + String cert = "-----BEGIN CERTIFICATE-----\n" + + "MIIDJzCCAg+gAwIBAgIIclOz1VulWC8wDQYJKoZIhvcNAQEMBQAwQjELMAkGA1UE\n" + + "BhMCVVMxFTATBgNVBAoTDEV4YW1wbGUgQ29ycDEMMAoGA1UEAxMDYm9iMQ4wDAYD\n" + + "VQQDEwVhbGljZTAeFw0yNjA2MDExOTQzMTVaFw0zNjA1MjkxOTQzMTVaMEIxCzAJ\n" + + "BgNVBAYTAlVTMRUwEwYDVQQKEwxFeGFtcGxlIENvcnAxDDAKBgNVBAMTA2JvYjEO\n" + + "MAwGA1UEAxMFYWxpY2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCX\n" + + "q8hZrTRHJEN7+D6yK65OKeCTVU+WccI6awz6g4T6O3aoC+1IiUljsEYn+xuDfx5L\n" + + "L/O1kejjmbYt+vzRmiILoJ9xKfW3ERcB4+gEar959Dkj6wmpsgOKRjmOvcOFOkEe\n" + + "gU1F7t04JHOou3DaAkHMNMQV+3jsWSh9Rry7XZBDkcT8XbbagoCgSIIef03Qtw31\n" + + "zOBwqmJmd6CQhFJva5cl8cCE2xZinOIiz/2j7VprZTjhkud2M4bRnK3T0WExyOCg\n" + + "jvjSf5ZoOKwC2Z9Q/Oyf8WKpren1+GfZhAKKmn7QZ2foHhdPPRtNjRGE6SqQyW2u\n" + + "8+1tOXl+aRCF19rR7gIpAgMBAAGjITAfMB0GA1UdDgQWBBRfLtnK5WKU0q3zxIrr\n" + + "y3GD+lYplzANBgkqhkiG9w0BAQwFAAOCAQEAe+/FHqErVPsF/sHrVHny8mIsn3ux\n" + + "qE9P24KNF0oIfmBrAqqge6hoVQ8PS+JialyqFf//osuDjiuYaBEKBw7GCoA6I8mr\n" + + "FA7wyFaGosfq7An5vxkJl7lap2u5oSVv3dCy13Bs0ziYmNlTkfHDLy9yh7jpH1wg\n" + + "TvylH9O4Vc7y9rzzpIjMCuQJ/wJ4MuJ2mSarYZsx3UQHIRfpKtR/9jbFMX1Rbv/A\n" + + "N0XD+NrtFjiikp71y3aCO1EHnGG7qPKCWh3PzaNoWFpyZKDBvud8ymW7RMiLPgv9\n" + + "eTa82KGgnCwtKCNzGkszIn7fza/6xnCuzvh4y9tYE5BWP2mcMRtRmDD07w==\n" + "-----END CERTIFICATE-----"; + ByteArrayInputStream in = new ByteArrayInputStream(cert.getBytes()); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(in); + } + }