1
0
mirror of synced 2026-07-07 03:40:04 +00:00

Sync branch '6.5.x'

This commit is contained in:
Joe Grandja
2026-06-09 09:47:13 -04:00
66 changed files with 2695 additions and 664 deletions
@@ -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<H extends HttpSecurityBuilder<H>>
@@ -161,7 +162,10 @@ public final class X509Configurer<H extends HttpSecurityBuilder<H>>
* @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<H> subjectPrincipalRegex(String subjectPrincipalRegex) {
SubjectDnX509PrincipalExtractor principalExtractor = new SubjectDnX509PrincipalExtractor();
principalExtractor.setSubjectDnRegex(subjectPrincipalRegex);
@@ -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);
@@ -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 {
* }
* </pre>
*
* 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 {
* }
* </pre>
*
* 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() {
@@ -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.
@@ -2911,6 +2911,12 @@
</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="principal-extractor-ref" type="xs:token">
<xs:annotation>
<xs:documentation>Reference to an X509PrincipalExtractor which will be used by the authentication filter
</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:attributeGroup>
<xs:element name="jee">
<xs:annotation>
@@ -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 {
@@ -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 <x509>\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=(.*?)(?:,|$)");
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2002-2018 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.
-->
<b:beans xmlns:b="http://www.springframework.org/schema/beans"
xmlns:p="http://www.springframework.org/schema/p"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/security"
xsi:schemaLocation="
http://www.springframework.org/schema/security
https://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
<http>
<x509 principal-extractor-ref="principalExtractor"/>
<intercept-url pattern="/**" access="authenticated"/>
</http>
<user-service id="us">
<user name="luke@monkeymachine" password="{noop}password" authorities="ROLE_USER"/>
</user-service>
<b:bean name="principalExtractor" class="org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor"
p:extractPrincipalNameFromEmail="true"/>
<b:import resource="MiscHttpConfigTests-controllers.xml"/>
</b:beans>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2002-2018 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.
-->
<b:beans xmlns:b="http://www.springframework.org/schema/beans"
xmlns:p="http://www.springframework.org/schema/p"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/security"
xsi:schemaLocation="
http://www.springframework.org/schema/security
https://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
<http>
<x509 principal-extractor-ref="principalExtractor" subject-principal-regex="(.*)"/>
<intercept-url pattern="/**" access="authenticated"/>
</http>
<user-service id="us">
<user name="luke@monkeymachine" password="{noop}password" authorities="ROLE_USER"/>
</user-service>
<b:bean name="principalExtractor" class="org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor"
p:extractPrincipalNameFromEmail="true"/>
<b:import resource="MiscHttpConfigTests-controllers.xml"/>
</b:beans>
+1
View File
@@ -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'
@@ -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.
@@ -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]]
== <filter-chain-map>
Used to explicitly configure a FilterChainProxy instance with a FilterChainMap
@@ -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 `<x509/>` element to your http security namespace configuration:
[source,xml]
----
<http>
...
<x509 subject-principal-regex="CN=(.*?)," user-service-ref="userService"/>;
</http>
----
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
@@ -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[]
}
@@ -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);
}
}
@@ -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<Void> 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 extends Certificate> 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);
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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";
}
}
}
@@ -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[]
}
@@ -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)
}
}
@@ -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<WebTestClient.ControllerSpec>(springSecurityFilterChain)
.apply<WebTestClient.ControllerSpec>(SecurityMockServerConfigurers.springSecurity())
.configureClient()
.build()
}
@Test
fun x509WhenDefaultX509Configuration() {
this.spring.register(DefaultX509Configuration::class.java).autowire()
val certificate = loadCert<X509Certificate>("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<Void> {
val sslInfoRequest = exchange.getRequest().mutate().sslInfo(sslInfo)
.build()
val sslInfoExchange = exchange.mutate().request(sslInfoRequest).build()
return chain.filter(sslInfoExchange)
}
}
private fun <T : Certificate?> 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<X509Certificate?> {
return arrayOf(certificate)
}
}
httpHandlerBuilder?.filters(Consumer { filters: MutableList<WebFilter> ->
filters.add(
0,
SslInfoOverrideWebFilter(sslInfo)
)
})
}
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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"
}
}
}
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2002-2018 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.
-->
<b:beans xmlns:b="http://www.springframework.org/schema/beans"
xmlns:p="http://www.springframework.org/schema/p"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/security"
xsi:schemaLocation="
http://www.springframework.org/schema/security
https://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- tag::springSecurity[] -->
<http>
<intercept-url pattern="/**" access="authenticated"/>
<x509 principal-extractor-ref="principalExtractor"/>
</http>
<b:bean id="principalExtractor"
class="org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor"
p:extractPrincipalNameFromEmail="true"/>
<!-- end::springSecurity[] -->
<user-service id="us">
<user name="luke@monkeymachine" password="{noop}password" authorities="ROLE_USER"/>
</user-service>
<b:import resource="MiscHttpConfigTests-controllers.xml"/>
</b:beans>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2002-2018 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.
-->
<b:beans xmlns:b="http://www.springframework.org/schema/beans"
xmlns:p="http://www.springframework.org/schema/p"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/security"
xsi:schemaLocation="
http://www.springframework.org/schema/security
https://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- tag::springSecurity[] -->
<http>
<intercept-url pattern="/**" access="authenticated"/>
<x509 />
</http>
<!-- end::springSecurity[] -->
<user-service>
<user name="rod" password="{noop}password" authorities="ROLE_USER"/>
</user-service>
</b:beans>
@@ -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++;
}
}
}
@@ -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<Saml2Error> responseSignatureErrors = this.responseSignatureValidator.convert(responseToken)
.getErrors();
if (!responseSignatureErrors.isEmpty()) {
reportErrors(response, responseSignatureErrors);
return;
}
Collection<Saml2Error> 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<Saml2Error> 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<Saml2Error> 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<Saml2Error> 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<ResponseToken, Saml2ResponseValidatorResult> createDefaultResponseSignatureValidator() {
@@ -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++;
}
}
}
@@ -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<Saml2Error> 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<Collection<Saml2Error>> verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest,
private Collection<Saml2Error> verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest,
RelyingPartyRegistration registration) {
AssertingPartyMetadata details = registration.getAssertingPartyMetadata();
Collection<Saml2X509Credential> 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<Collection<Saml2Error>> 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<Saml2Error> validateRequest(LogoutRequest request, RelyingPartyRegistration registration,
Authentication authentication) {
Collection<Saml2Error> errors = new ArrayList<>();
errors.addAll(validateIssuer(request, registration));
errors.addAll(validateDestination(request, registration));
errors.addAll(validateSubject(request, registration, authentication));
return errors;
}
private Consumer<Collection<Saml2Error>> 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<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Saml2Error> errors) {
private Collection<Saml2Error> 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();
}
}
@@ -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<Saml2Error> 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<Collection<Saml2Error>> verifySignature(Saml2LogoutResponse response,
LogoutResponse logoutResponse, RelyingPartyRegistration registration) {
return (errors) -> {
AssertingPartyMetadata details = registration.getAssertingPartyMetadata();
Collection<Saml2X509Credential> 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<Collection<Saml2Error>> validateRequest(LogoutResponse response,
private Collection<Saml2Error> 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<Saml2X509Credential> 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<Collection<Saml2Error>> 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<Saml2Error> validateRequest(LogoutResponse response, RelyingPartyRegistration registration,
String logoutRequestId) {
Collection<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Saml2Error> 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"));
}
}
@@ -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++;
}
}
}
@@ -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++;
}
}
}
@@ -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++;
}
}
}
@@ -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++;
}
}
}
@@ -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<AbstractSaml2AuthenticationRequest> 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("<!DOCTYPE html>\n");
html.append("<html>\n").append(" <head>\n");
html.append(" <meta http-equiv=\"Content-Security-Policy\" ")
.append("content=\"script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='\">\n");
html.append(" <meta charset=\"utf-8\" />\n");
html.append(" </head>\n");
html.append(" <body>\n");
html.append(" <noscript>\n");
html.append(" <p>\n");
html.append(" <strong>Note:</strong> Since your browser does not support JavaScript,\n");
html.append(" you must press the Continue button once to proceed.\n");
html.append(" </p>\n");
html.append(" </noscript>\n");
html.append(" \n");
html.append(" <form action=\"");
html.append(authenticationRequestUri);
html.append("\" method=\"post\">\n");
html.append(" <div>\n");
html.append(" <input type=\"hidden\" name=\"SAMLRequest\" value=\"");
html.append(HtmlUtils.htmlEscape(samlRequest));
html.append("\"/>\n");
if (StringUtils.hasText(relayState)) {
html.append(" <input type=\"hidden\" name=\"RelayState\" value=\"");
html.append(HtmlUtils.htmlEscape(relayState));
html.append("\"/>\n");
}
html.append(" </div>\n");
html.append(" <noscript>\n");
html.append(" <div>\n");
html.append(" <input type=\"submit\" value=\"Continue\"/>\n");
html.append(" </div>\n");
html.append(" </noscript>\n");
html.append(" </form>\n");
html.append(" \n");
html.append(" <script>window.onload = function() { document.forms[0].submit(); }</script>\n");
html.append(" </body>\n");
html.append("</html>");
return html.toString();
UriComponentsBuilder uriBuilder = UriComponentsBuilder
.fromUriString(authenticationRequest.getAuthenticationRequestUri());
addParameter(Saml2ParameterNames.SAML_REQUEST, authenticationRequest.getSamlRequest(), uriBuilder);
addParameter(Saml2ParameterNames.RELAY_STATE, authenticationRequest.getRelayState(), uriBuilder);
this.formPostRedirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
}
}
@@ -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++;
}
}
}
@@ -17,6 +17,7 @@
package org.springframework.security.saml2.provider.service.web.authentication.logout;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -26,7 +27,6 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
@@ -46,6 +46,7 @@ import org.springframework.security.saml2.provider.service.web.RelyingPartyRegis
import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationPlaceholderResolvers.UriResolver;
import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.FormPostRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.logout.CompositeLogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutHandler;
@@ -54,8 +55,8 @@ import org.springframework.security.web.util.matcher.RequestMatcher;
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;
/**
* A filter for handling logout requests in the form of a &lt;saml2:LogoutRequest&gt; sent
@@ -82,6 +83,8 @@ public final class Saml2LogoutRequestFilter extends OncePerRequestFilter {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
private final RedirectStrategy formPostRedirectStrategy = new FormPostRedirectStrategy();
public Saml2LogoutRequestFilter(Saml2LogoutRequestValidatorParametersResolver logoutRequestResolver,
Saml2LogoutRequestValidator logoutRequestValidator, Saml2LogoutResponseResolver logoutResponseResolver,
LogoutHandler... handlers) {
@@ -158,7 +161,7 @@ public final class Saml2LogoutRequestFilter extends OncePerRequestFilter {
doRedirect(request, response, logoutResponse);
}
else {
doPost(response, logoutResponse);
doPost(request, response, logoutResponse);
}
}
@@ -189,55 +192,21 @@ public final class Saml2LogoutRequestFilter extends OncePerRequestFilter {
this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
}
private void doPost(HttpServletResponse response, Saml2LogoutResponse logoutResponse) throws IOException {
private void doPost(HttpServletRequest request, HttpServletResponse response, Saml2LogoutResponse logoutResponse)
throws IOException {
String location = logoutResponse.getResponseLocation();
String saml = logoutResponse.getSamlResponse();
String relayState = logoutResponse.getRelayState();
String html = createSamlPostRequestFormData(location, saml, relayState);
response.setContentType(MediaType.TEXT_HTML_VALUE);
response.getWriter().write(html);
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(location);
addParameter(Saml2ParameterNames.SAML_RESPONSE, logoutResponse.getSamlResponse(), uriBuilder);
addParameter(Saml2ParameterNames.RELAY_STATE, logoutResponse.getRelayState(), uriBuilder);
this.formPostRedirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
}
private String createSamlPostRequestFormData(String location, String saml, String relayState) {
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html>\n");
html.append("<html>\n").append(" <head>\n");
html.append(" <meta http-equiv=\"Content-Security-Policy\" ")
.append("content=\"script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='\">\n");
html.append(" <meta charset=\"utf-8\" />\n");
html.append(" </head>\n");
html.append(" <body>\n");
html.append(" <noscript>\n");
html.append(" <p>\n");
html.append(" <strong>Note:</strong> Since your browser does not support JavaScript,\n");
html.append(" you must press the Continue button once to proceed.\n");
html.append(" </p>\n");
html.append(" </noscript>\n");
html.append(" \n");
html.append(" <form action=\"");
html.append(location);
html.append("\" method=\"post\">\n");
html.append(" <div>\n");
html.append(" <input type=\"hidden\" name=\"SAMLResponse\" value=\"");
html.append(HtmlUtils.htmlEscape(saml));
html.append("\"/>\n");
if (StringUtils.hasText(relayState)) {
html.append(" <input type=\"hidden\" name=\"RelayState\" value=\"");
html.append(HtmlUtils.htmlEscape(relayState));
html.append("\"/>\n");
private void addParameter(String name, String value, UriComponentsBuilder builder) {
Assert.hasText(name, "name cannot be empty or null");
if (StringUtils.hasText(value)) {
builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1),
UriUtils.encode(value, StandardCharsets.ISO_8859_1));
}
html.append(" </div>\n");
html.append(" <noscript>\n");
html.append(" <div>\n");
html.append(" <input type=\"submit\" value=\"Continue\"/>\n");
html.append(" </div>\n");
html.append(" </noscript>\n");
html.append(" </form>\n");
html.append(" \n");
html.append(" <script>window.onload = function() { document.forms[0].submit(); }</script>\n");
html.append(" </body>\n");
html.append("</html>");
return html.toString();
}
private static class Saml2AssertingPartyLogoutRequestResolver
@@ -17,23 +17,25 @@
package org.springframework.security.saml2.provider.service.web.authentication.logout;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.saml2.core.Saml2ParameterNames;
import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.FormPostRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
/**
* A success handler for issuing a SAML 2.0 Logout Request to the SAML 2.0 Asserting Party
@@ -49,6 +51,8 @@ public final class Saml2RelyingPartyInitiatedLogoutSuccessHandler implements Log
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
private final RedirectStrategy formPostRedirectStrategy = new FormPostRedirectStrategy();
private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository();
/**
@@ -82,7 +86,7 @@ public final class Saml2RelyingPartyInitiatedLogoutSuccessHandler implements Log
doRedirect(request, response, logoutRequest);
}
else {
doPost(response, logoutRequest);
doPost(request, response, logoutRequest);
}
}
@@ -104,55 +108,21 @@ public final class Saml2RelyingPartyInitiatedLogoutSuccessHandler implements Log
this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
}
private void doPost(HttpServletResponse response, Saml2LogoutRequest logoutRequest) throws IOException {
private void doPost(HttpServletRequest request, HttpServletResponse response, Saml2LogoutRequest logoutRequest)
throws IOException {
String location = logoutRequest.getLocation();
String saml = logoutRequest.getSamlRequest();
String relayState = logoutRequest.getRelayState();
String html = createSamlPostRequestFormData(location, saml, relayState);
response.setContentType(MediaType.TEXT_HTML_VALUE);
response.getWriter().write(html);
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(location);
addParameter(Saml2ParameterNames.SAML_REQUEST, logoutRequest.getSamlRequest(), uriBuilder);
addParameter(Saml2ParameterNames.RELAY_STATE, logoutRequest.getRelayState(), uriBuilder);
this.formPostRedirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString());
}
private String createSamlPostRequestFormData(String location, String saml, String relayState) {
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html>\n");
html.append("<html>\n").append(" <head>\n");
html.append(" <meta http-equiv=\"Content-Security-Policy\" ")
.append("content=\"script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='\">\n");
html.append(" <meta charset=\"utf-8\" />\n");
html.append(" </head>\n");
html.append(" <body>\n");
html.append(" <noscript>\n");
html.append(" <p>\n");
html.append(" <strong>Note:</strong> Since your browser does not support JavaScript,\n");
html.append(" you must press the Continue button once to proceed.\n");
html.append(" </p>\n");
html.append(" </noscript>\n");
html.append(" \n");
html.append(" <form action=\"");
html.append(location);
html.append("\" method=\"post\">\n");
html.append(" <div>\n");
html.append(" <input type=\"hidden\" name=\"SAMLRequest\" value=\"");
html.append(HtmlUtils.htmlEscape(saml));
html.append("\"/>\n");
if (StringUtils.hasText(relayState)) {
html.append(" <input type=\"hidden\" name=\"RelayState\" value=\"");
html.append(HtmlUtils.htmlEscape(relayState));
html.append("\"/>\n");
private void addParameter(String name, String value, UriComponentsBuilder builder) {
Assert.hasText(name, "name cannot be empty or null");
if (StringUtils.hasText(value)) {
builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1),
UriUtils.encode(value, StandardCharsets.ISO_8859_1));
}
html.append(" </div>\n");
html.append(" <noscript>\n");
html.append(" <div>\n");
html.append(" <input type=\"submit\" value=\"Continue\"/>\n");
html.append(" </div>\n");
html.append(" </noscript>\n");
html.append(" </form>\n");
html.append(" \n");
html.append(" <script>window.onload = function() { document.forms[0].submit(); }</script>\n");
html.append(" </body>\n");
html.append("</html>");
return html.toString();
}
}
@@ -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++;
}
}
}
@@ -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<Saml2Error> 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<Collection<Saml2Error>> verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest,
private Collection<Saml2Error> verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest,
RelyingPartyRegistration registration) {
AssertingPartyMetadata details = registration.getAssertingPartyMetadata();
Collection<Saml2X509Credential> 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<Collection<Saml2Error>> 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<Saml2Error> validateRequest(LogoutRequest request, RelyingPartyRegistration registration,
Authentication authentication) {
Collection<Saml2Error> errors = new ArrayList<>();
errors.addAll(validateIssuer(request, registration));
errors.addAll(validateDestination(request, registration));
errors.addAll(validateSubject(request, registration, authentication));
return errors;
}
private Consumer<Collection<Saml2Error>> 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<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Saml2Error> errors) {
private Collection<Saml2Error> 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();
}
}
@@ -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<Saml2Error> 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<Collection<Saml2Error>> verifySignature(Saml2LogoutResponse response,
LogoutResponse logoutResponse, RelyingPartyRegistration registration) {
return (errors) -> {
AssertingPartyMetadata details = registration.getAssertingPartyMetadata();
Collection<Saml2X509Credential> 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<Collection<Saml2Error>> validateRequest(LogoutResponse response,
private Collection<Saml2Error> 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<Saml2X509Credential> 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<Collection<Saml2Error>> 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<Saml2Error> validateRequest(LogoutResponse response, RelyingPartyRegistration registration,
String logoutRequestId) {
Collection<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Collection<Saml2Error>> 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<Saml2Error> 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<Saml2Error> 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"));
}
}
@@ -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<ResponseToken, Saml2ResponseValidatorResult> responseValidator = mock(Converter.class);
Converter<OpenSaml4AuthenticationProvider.AssertionToken, Saml2ResponseValidatorResult> 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<OpenSaml4AuthenticationProvider.AssertionToken, Saml2ResponseValidatorResult> 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<OpenSaml4AuthenticationProvider.AssertionToken> 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))
@@ -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();
@@ -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() {
@@ -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();
@@ -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() {
@@ -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<ResponseToken, Saml2ResponseValidatorResult> responseValidator = mock(Converter.class);
Converter<OpenSaml5AuthenticationProvider.AssertionToken, Saml2ResponseValidatorResult> 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<OpenSaml5AuthenticationProvider.AssertionToken, Saml2ResponseValidatorResult> 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<OpenSaml5AuthenticationProvider.AssertionToken> 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))
@@ -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();
@@ -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() {
@@ -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");
}
}
@@ -178,12 +178,10 @@ public class Saml2WebSsoAuthenticationRequestFilterTests {
given(this.authenticationRequestResolver.resolve(any())).willReturn(request);
this.filter.doFilterInternal(this.request, this.response, this.filterChain);
assertThat(this.response.getHeader("Location")).isNull();
assertThat(this.response.getContentAsString()).contains(
"<meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='\">")
.contains("<script>window.onload = function() { document.forms[0].submit(); }</script>")
.contains("<form action=\"https://sso-url.example.com/IDP/SSO\" method=\"post\">")
.contains("<input type=\"hidden\" name=\"SAMLRequest\"")
assertThat(this.response.getContentAsString()).contains("action=\"https://sso-url.example.com/IDP/SSO\"")
.contains("name=\"SAMLRequest\"")
.contains("value=\"" + relayStateEncoded + "\"");
assertThat(this.response.getHeader("Content-Security-Policy")).matches("script-src 'nonce-.+'");
}
@Test
@@ -120,9 +120,7 @@ public class Saml2LogoutRequestFilterTests {
String content = response.getContentAsString();
assertThat(content).contains(Saml2ParameterNames.SAML_RESPONSE);
assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation());
assertThat(content).contains(
"<meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='\">");
assertThat(content).contains("<script>window.onload = function() { document.forms[0].submit(); }</script>");
assertThat(response.getHeader("Content-Security-Policy")).matches("script-src 'nonce-.+'");
verify(this.securityContextHolderStrategy).getContext();
}
@@ -100,9 +100,7 @@ public class Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests {
String content = response.getContentAsString();
assertThat(content).contains(Saml2ParameterNames.SAML_REQUEST);
assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation());
assertThat(content).contains(
"<meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='\">");
assertThat(content).contains("<script>window.onload = function() { document.forms[0].submit(); }</script>");
assertThat(response.getHeader("Content-Security-Policy")).matches("script-src 'nonce-.+'");
}
private Saml2Authentication authentication(RelyingPartyRegistration registration) {
@@ -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());
@@ -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<Rdn> getDns(String subjectDn) {
try {
// read most-specific first, see gh-19254
List<Rdn> 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
* <a href="https://datatracker.ietf.org/doc/html/rfc2253#section-2.3">Converting
* AttributeTypeAndValue of RFC2253</a>.
*
* 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
* <a href="https://oid-base.com/get/1.2.840.113549.1.9.1">OID.1.2.840.113549.1.9.1
* (emailAddress)</a> attribute as defined in
* <a href="https://datatracker.ietf.org/doc/html/rfc1779#section-2.3">Section 2.3 of
* RFC1779</a>.
* @param extractPrincipalNameFromEmail whether to extract the principal from the
* emailAddress (default false)
* @see <a href="https://datatracker.ietf.org/doc/html/rfc2253">RFC2253</a>
* @see <a href="https://datatracker.ietf.org/doc/html/rfC1779">RFC1779</a>
*/
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;
}
}
}
@@ -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) {
@@ -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);
@@ -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() {
@@ -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));
@@ -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));
}
}
@@ -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:
*
* <pre>
* CN=alice, CN=bob, O=Example Corp, C=US
* </pre>
*
* 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);
}
}
@@ -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&param2=b&param3=1122");
assertThat(redirectUrl).isEqualTo("/destination?param1=a&param2=b&param3=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&param2=b&param3=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&param2=b&param3=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&param2=b&param3=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&param2=b&param3=1122");
String redirectUrl = "https://abc.com/destination?param1=a&param2=b&param3=1122";
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl)));
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination?param1=a&param2=b&param3=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);
@@ -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();
@@ -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