Sync branch '6.5.x'
This commit is contained in:
+4
@@ -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);
|
||||
|
||||
+15
-3
@@ -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);
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
+80
@@ -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 {
|
||||
|
||||
+23
@@ -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=(.*?)(?:,|$)");
|
||||
|
||||
+41
@@ -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>
|
||||
+41
@@ -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>
|
||||
@@ -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
|
||||
|
||||
+74
@@ -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[]
|
||||
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+148
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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[]
|
||||
|
||||
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+131
@@ -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)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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)
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+75
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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>
|
||||
+39
@@ -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>
|
||||
+25
-1
@@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+37
-24
@@ -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() {
|
||||
|
||||
+25
-1
@@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+59
-67
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+75
-85
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-1
@@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-1
@@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-1
@@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-1
@@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+9
-50
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-1
@@ -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
-48
@@ -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 <saml2:LogoutRequest> 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
|
||||
|
||||
+18
-48
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-1
@@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+59
-67
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+76
-84
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+50
-1
@@ -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))
|
||||
|
||||
+33
@@ -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();
|
||||
|
||||
+47
-3
@@ -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() {
|
||||
|
||||
+33
@@ -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();
|
||||
|
||||
+47
-3
@@ -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() {
|
||||
|
||||
+48
-1
@@ -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))
|
||||
|
||||
+33
@@ -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();
|
||||
|
||||
+47
-3
@@ -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() {
|
||||
|
||||
+40
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
+3
-5
@@ -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
|
||||
|
||||
+1
-3
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -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) {
|
||||
|
||||
+2
@@ -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());
|
||||
|
||||
+137
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+19
-15
@@ -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);
|
||||
|
||||
+12
-4
@@ -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() {
|
||||
|
||||
+7
@@ -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));
|
||||
|
||||
+90
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+45
-12
@@ -54,7 +54,7 @@ public class CookieRequestCacheTests {
|
||||
Cookie savedCookie = response.getCookie(DEFAULT_COOKIE_NAME);
|
||||
assertThat(savedCookie).isNotNull();
|
||||
String redirectUrl = decodeCookie(savedCookie.getValue());
|
||||
assertThat(redirectUrl).isEqualTo("https://abc.com/destination?param1=a¶m2=b¶m3=1122");
|
||||
assertThat(redirectUrl).isEqualTo("/destination?param1=a¶m2=b¶m3=1122");
|
||||
assertThat(savedCookie.getMaxAge()).isEqualTo(-1);
|
||||
assertThat(savedCookie.getPath()).isEqualTo("/");
|
||||
assertThat(savedCookie.isHttpOnly()).isTrue();
|
||||
@@ -101,18 +101,53 @@ public class CookieRequestCacheTests {
|
||||
public void getRequestWhenRequestContainsSavedRequestCookieThenReturnsSaveRequest() {
|
||||
CookieRequestCache cookieRequestCache = new CookieRequestCache();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
String redirectUrl = "https://abc.com/destination?param1=a¶m2=b¶m3=1122";
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl)));
|
||||
request.setScheme("https");
|
||||
request.setServerName("abc.com");
|
||||
request.setServerPort(443);
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination?param1=a¶m2=b¶m3=1122")));
|
||||
SavedRequest savedRequest = cookieRequestCache.getRequest(request, new MockHttpServletResponse());
|
||||
assertThat(savedRequest).isNotNull();
|
||||
assertThat(savedRequest.getRedirectUrl()).isEqualTo(redirectUrl);
|
||||
assertThat(savedRequest.getRedirectUrl())
|
||||
.isEqualTo("https://abc.com/destination?param1=a¶m2=b¶m3=1122");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getRequestWhenRelativePathInCookieThenRedirectUrlUsesCurrentRequestOrigin() {
|
||||
CookieRequestCache cookieRequestCache = new CookieRequestCache();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setScheme("https");
|
||||
request.setServerName("myapp.com");
|
||||
request.setServerPort(443);
|
||||
request.setSecure(true);
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/secret?token=abc")));
|
||||
SavedRequest savedRequest = cookieRequestCache.getRequest(request, new MockHttpServletResponse());
|
||||
assertThat(savedRequest).isNotNull();
|
||||
assertThat(savedRequest.getRedirectUrl()).isEqualTo("https://myapp.com/secret?token=abc");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getRequestWhenAbsoluteUrlInCookieThenReturnsNull() {
|
||||
CookieRequestCache cookieRequestCache = new CookieRequestCache();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("https://evil.com/phishing")));
|
||||
SavedRequest savedRequest = cookieRequestCache.getRequest(request, new MockHttpServletResponse());
|
||||
assertThat(savedRequest).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getRequestWhenProtocolRelativeUrlInCookieThenReturnsNull() {
|
||||
CookieRequestCache cookieRequestCache = new CookieRequestCache();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("//evil.com/phishing")));
|
||||
SavedRequest savedRequest = cookieRequestCache.getRequest(request, new MockHttpServletResponse());
|
||||
assertThat(savedRequest).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getRequestWhenRequestContainsSavedRequestCookieThenSavedRequestContainsRequestParameters() {
|
||||
CookieRequestCache cookieRequestCache = new CookieRequestCache();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("https://abc.com/destination")));
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination")));
|
||||
request.setParameter("single", "first");
|
||||
request.addParameter("multi", "second");
|
||||
request.addParameter("multi", "third");
|
||||
@@ -143,8 +178,7 @@ public class CookieRequestCacheTests {
|
||||
request.setServerName("abc.com");
|
||||
request.setRequestURI("/destination");
|
||||
request.setQueryString("param1=a¶m2=b¶m3=1122");
|
||||
String redirectUrl = "https://abc.com/destination?param1=a¶m2=b¶m3=1122";
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl)));
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination?param1=a¶m2=b¶m3=1122")));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
cookieRequestCache.getMatchingRequest(request, response);
|
||||
Cookie expiredCookie = response.getCookie(DEFAULT_COOKIE_NAME);
|
||||
@@ -162,8 +196,7 @@ public class CookieRequestCacheTests {
|
||||
request.setScheme("https");
|
||||
request.setServerName("abc.com");
|
||||
request.setRequestURI("/destination");
|
||||
String redirectUrl = "https://abc.com/api";
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl)));
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/api")));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
final HttpServletRequest matchingRequest = cookieRequestCache.getMatchingRequest(request, response);
|
||||
assertThat(matchingRequest).isNull();
|
||||
@@ -182,8 +215,8 @@ public class CookieRequestCacheTests {
|
||||
request.setRequestURI("/destination");
|
||||
request.setQueryString("goto=https%3A%2F%2Fstart.spring.io");
|
||||
request.setParameter("goto", "https://start.spring.io");
|
||||
String redirectUrl = "https://abc.com/destination?goto=https%3A%2F%2Fstart.spring.io";
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl)));
|
||||
request.setCookies(
|
||||
new Cookie(DEFAULT_COOKIE_NAME, encodeCookie("/destination?goto=https%3A%2F%2Fstart.spring.io")));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
final HttpServletRequest matchingRequest = cookieRequestCache.getMatchingRequest(request, response);
|
||||
assertThat(matchingRequest).isNotNull();
|
||||
@@ -212,7 +245,7 @@ public class CookieRequestCacheTests {
|
||||
request.setServerName("example.com");
|
||||
request.setRequestURI("/destination");
|
||||
request.setPreferredLocales(Arrays.asList(Locale.FRENCH, Locale.GERMANY));
|
||||
String redirectUrl = "https://example.com/destination";
|
||||
String redirectUrl = "/destination";
|
||||
request.setCookies(new Cookie(DEFAULT_COOKIE_NAME, encodeCookie(redirectUrl)));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
HttpServletRequest matchingRequest = cookieRequestCache.getMatchingRequest(request, response);
|
||||
|
||||
+4
-5
@@ -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();
|
||||
|
||||
+20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user