Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b716503bd | |||
| 2c4db99229 | |||
| 0f6f453ea0 | |||
| 1eef373ca0 | |||
| 7a03cd5a55 | |||
| e4fafce066 | |||
| 2104c49597 | |||
| c5b1f78118 | |||
| d2366650ec | |||
| 5338b7a05b | |||
| bd86d0b233 | |||
| f8442794a8 | |||
| 57c2af4900 | |||
| 6da4ea0d0d | |||
| 8f69721f24 | |||
| acd131c3d1 | |||
| 73b077790f | |||
| 62c4d60524 | |||
| d884913175 | |||
| fd5dae5e4d | |||
| d29c761135 | |||
| 700a4533aa | |||
| 3a394c633f | |||
| 79f9bec818 | |||
| 5b019365b7 | |||
| 038da0289e | |||
| f9b4afdfc5 | |||
| 65aaedbc83 | |||
| f325ddbdaa | |||
| bc9bdbaf83 | |||
| 6da29433cc | |||
| 4adfdf66b9 | |||
| e91b81a5aa | |||
| 0949d3b3ea | |||
| d44efc899d | |||
| c5870a746a | |||
| 58bca0e895 | |||
| 1bc3fd9ea9 | |||
| e84b949719 | |||
| 33bd1abb9d | |||
| ea62b200f7 | |||
| 5eb41b8a06 | |||
| 8f0c077fc7 | |||
| 430b9bb8c6 | |||
| 0fa81bfb6d | |||
| 54e9575391 | |||
| e3ad551ab3 | |||
| 7fc97c6ee2 | |||
| 241bf2c148 | |||
| cf06871200 | |||
| e50c2a6a74 | |||
| b175e4487e | |||
| 356131b1ea | |||
| 97a49aa3bf | |||
| e4a78c26d6 | |||
| c50e4c02bb | |||
| a14c9d66b1 | |||
| 2924dd29f7 | |||
| 9d4d9065b4 | |||
| 3f1a794cd9 | |||
| c5de4096b4 | |||
| 95d375c8d8 | |||
| b486d2695a | |||
| 8b34113c2a | |||
| fe2e52f646 | |||
| 0ea87ca3bf | |||
| 71fd23429c | |||
| 85514a9b34 | |||
| d039e0191f | |||
| cfdadfea01 | |||
| f70aa75445 | |||
| 86e0f1341e | |||
| cea6618265 | |||
| 5bf8f641f1 | |||
| 8b9f556d8f | |||
| d9b8a8ec54 | |||
| 4e04c266be | |||
| dc5c65eaf1 | |||
| 0353616be3 | |||
| 47853e1314 | |||
| 90e1658afe | |||
| e1d94d1e06 | |||
| a07c67a365 | |||
| 16cb219e4a | |||
| 944149ba71 | |||
| 9b14465243 | |||
| fdc0fd23df | |||
| 931dfbe8e7 | |||
| b075f0df02 | |||
| 8720a282ce | |||
| 53bc6a7796 | |||
| aed95aa2b1 | |||
| 6343002b32 | |||
| 7b57a4a3bb | |||
| 0a9d4dc8fc |
@@ -18,13 +18,11 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- java-version: 21-ea
|
||||
toolchain: 21
|
||||
- java-version: 17
|
||||
toolchain: 17
|
||||
with:
|
||||
java-version: ${{ matrix.java-version }}
|
||||
test-args: --refresh-dependencies -PforceMavenRepositories=snapshot,https://oss.sonatype.org/content/repositories/snapshots -PisOverrideVersionCatalog -PtestToolchain=${{ matrix.toolchain }} -PspringFrameworkVersion=7.+ -PreactorVersion=2025.+ -PspringDataVersion=2025.+ --stacktrace
|
||||
test-args: --refresh-dependencies -PforceMavenRepositories=snapshot,https://oss.sonatype.org/content/repositories/snapshots -PisOverrideVersionCatalog -PtestToolchain=${{ matrix.toolchain }} -PspringFrameworkVersion=7.0.+ -PreactorVersion=2025.0.+ -PspringDataVersion=2025.1.+ -PmicrometerVersion=1.16.+ --stacktrace
|
||||
secrets: inherit
|
||||
send-notification:
|
||||
name: Send Notification
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
secrets: inherit
|
||||
deploy-artifacts:
|
||||
name: Deploy Artifacts
|
||||
needs: [ build]
|
||||
needs: [ build ]
|
||||
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-artifacts.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
|
||||
with:
|
||||
should-deploy-artifacts: ${{ needs.build.outputs.should-deploy-artifacts }}
|
||||
|
||||
@@ -15,24 +15,24 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Compute Version
|
||||
id: compute-version
|
||||
uses: spring-io/spring-release-actions/compute-version@2420148725bebe44bd59a575a9b1961ca4459b0b # 0.0.4
|
||||
uses: spring-io/spring-release-actions/compute-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||
- name: Get Today's Release Version
|
||||
id: todays-release
|
||||
uses: spring-io/spring-release-actions/get-todays-release-version@2420148725bebe44bd59a575a9b1961ca4459b0b # 0.0.4
|
||||
uses: spring-io/spring-release-actions/get-todays-release-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||
with:
|
||||
snapshot-version: ${{ steps.compute-version.outputs.version }}
|
||||
milestone-repository: ${{ github.repository }}
|
||||
milestone-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Compute Next Version
|
||||
id: next-version
|
||||
uses: spring-io/spring-release-actions/compute-next-version@2420148725bebe44bd59a575a9b1961ca4459b0b # 0.0.4
|
||||
uses: spring-io/spring-release-actions/compute-next-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||
with:
|
||||
version: ${{ steps.todays-release.outputs.release-version }}
|
||||
- name: Schedule Next Milestone
|
||||
uses: spring-io/spring-release-actions/schedule-milestone@2420148725bebe44bd59a575a9b1961ca4459b0b # 0.0.4
|
||||
uses: spring-io/spring-release-actions/schedule-milestone@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
|
||||
with:
|
||||
version: ${{ steps.next-version.outputs.version }}
|
||||
version-date: ${{ steps.next-version.outputs.version-date }}
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
if: github.repository_owner == 'spring-projects'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: docs-build
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up gradle
|
||||
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
|
||||
with:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'spring-projects/spring-security' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up gradle
|
||||
uses: spring-io/spring-gradle-build-action@c8668747d7c264864c8c7f7026d0d277d14a78dc # v2.0.6
|
||||
with:
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Dispatch
|
||||
|
||||
+13
@@ -39,6 +39,9 @@ import org.springframework.util.ClassUtils;
|
||||
@Deprecated
|
||||
final class GlobalMethodSecuritySelector implements ImportSelector {
|
||||
|
||||
private static final boolean isAccessPresent = ClassUtils.isPresent(
|
||||
"org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor", null);
|
||||
|
||||
@Override
|
||||
public String[] selectImports(AnnotationMetadata importingClassMetadata) {
|
||||
Class<EnableGlobalMethodSecurity> annoType = EnableGlobalMethodSecurity.class;
|
||||
@@ -59,16 +62,26 @@ final class GlobalMethodSecuritySelector implements ImportSelector {
|
||||
boolean jsr250Enabled = attributes.getBoolean("jsr250Enabled");
|
||||
List<String> classNames = new ArrayList<>(4);
|
||||
if (isProxy) {
|
||||
assertAccessModulePresent();
|
||||
classNames.add(MethodSecurityMetadataSourceAdvisorRegistrar.class.getName());
|
||||
}
|
||||
classNames.add(autoProxyClassName);
|
||||
if (!skipMethodSecurityConfiguration) {
|
||||
assertAccessModulePresent();
|
||||
classNames.add(GlobalMethodSecurityConfiguration.class.getName());
|
||||
}
|
||||
if (jsr250Enabled) {
|
||||
assertAccessModulePresent();
|
||||
classNames.add(Jsr250MetadataSourceConfiguration.class.getName());
|
||||
}
|
||||
return classNames.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private static void assertAccessModulePresent() {
|
||||
Assert.state(isAccessPresent,
|
||||
() -> "@EnableGlobalMethodSecurity requires the spring-security-access dependency on the "
|
||||
+ "classpath. Please add spring-security-access, or migrate to @EnableMethodSecurity "
|
||||
+ "which does not require it.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
@@ -26,6 +26,7 @@ import org.springframework.context.annotation.AutoProxyRegistrar;
|
||||
import org.springframework.context.annotation.ImportSelector;
|
||||
import org.springframework.core.type.AnnotationMetadata;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,8 @@ import org.springframework.util.ClassUtils;
|
||||
*/
|
||||
class ReactiveMethodSecuritySelector implements ImportSelector {
|
||||
|
||||
private static final String METHOD_SECURITY_METADATA_SOURCE_ADVISOR = "org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor";
|
||||
|
||||
private static final boolean isDataPresent = ClassUtils
|
||||
.isPresent("org.springframework.security.data.aot.hint.AuthorizeReturnObjectDataHintsRegistrar", null);
|
||||
|
||||
@@ -56,6 +59,11 @@ class ReactiveMethodSecuritySelector implements ImportSelector {
|
||||
imports.add(ReactiveAuthorizationManagerMethodSecurityConfiguration.class.getName());
|
||||
}
|
||||
else {
|
||||
Assert.state(
|
||||
ClassUtils.isPresent(METHOD_SECURITY_METADATA_SOURCE_ADVISOR, ClassUtils.getDefaultClassLoader()),
|
||||
() -> "@EnableReactiveMethodSecurity(useAuthorizationManager = false) requires the "
|
||||
+ "spring-security-access dependency on the classpath. Please add spring-security-access, "
|
||||
+ "or use the default useAuthorizationManager = true which does not require it.");
|
||||
imports.add(ReactiveMethodSecurityConfiguration.class.getName());
|
||||
}
|
||||
if (isDataPresent) {
|
||||
|
||||
+3
-1
@@ -2035,7 +2035,9 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul
|
||||
*/
|
||||
public HttpSecurity securityMatcher(String... patterns) {
|
||||
List<RequestMatcher> matchers = new ArrayList<>();
|
||||
PathPatternRequestMatcher.Builder builder = getSharedObject(PathPatternRequestMatcher.Builder.class);
|
||||
ApplicationContext context = getSharedObject(ApplicationContext.class);
|
||||
PathPatternRequestMatcher.Builder builder = context.getBeanProvider(PathPatternRequestMatcher.Builder.class)
|
||||
.getIfUnique(() -> getSharedObject(PathPatternRequestMatcher.Builder.class));
|
||||
for (String pattern : patterns) {
|
||||
matchers.add(builder.matcher(pattern));
|
||||
}
|
||||
|
||||
+1
-3
@@ -238,9 +238,7 @@ class HttpSecurityConfiguration {
|
||||
Map<Class<?>, Object> sharedObjects = new HashMap<>();
|
||||
sharedObjects.put(ApplicationContext.class, this.context);
|
||||
sharedObjects.put(ContentNegotiationStrategy.class, this.contentNegotiationStrategy);
|
||||
sharedObjects.put(PathPatternRequestMatcher.Builder.class,
|
||||
this.context.getBeanProvider(PathPatternRequestMatcher.Builder.class)
|
||||
.getIfUnique(() -> constructRequestMatcherBuilder(this.context)));
|
||||
sharedObjects.put(PathPatternRequestMatcher.Builder.class, constructRequestMatcherBuilder(this.context));
|
||||
return sharedObjects;
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -301,8 +301,7 @@ class SpringSecurityCoreVersionSerializableTests {
|
||||
private static String getCurrentVersion() {
|
||||
String version = System.getProperty("springSecurityVersion");
|
||||
String[] parts = version.split("\\.");
|
||||
parts[2] = "x";
|
||||
return String.join(".", parts);
|
||||
return parts[0] + "." + parts[1] + ".x";
|
||||
}
|
||||
|
||||
private static String getPreviousVersion() {
|
||||
@@ -315,8 +314,7 @@ class SpringSecurityCoreVersionSerializableTests {
|
||||
parts[0] = String.valueOf(Integer.parseInt(parts[0]) - 1);
|
||||
parts[1] = "5"; // FIXME: this should not be hard coded
|
||||
}
|
||||
parts[2] = "x";
|
||||
return String.join(".", parts);
|
||||
return parts[0] + "." + parts[1] + ".x";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.config.annotation.method.configuration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.context.annotation.AdviceMode;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.test.support.ClassPathExclusions;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
/**
|
||||
* Tests for gh-19441: {@code spring-security-access} moved
|
||||
* {@link org.springframework.security.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor}
|
||||
* out of {@code spring-security-core} and into the optional
|
||||
* {@code spring-security-access} module. {@link EnableMethodSecurity} and
|
||||
* {@link EnableReactiveMethodSecurity}'s default (AuthorizationManager-based) mode never
|
||||
* needed that class and continue to work without {@code spring-security-access} on the
|
||||
* classpath, but the deprecated legacy method security annotations do need it and
|
||||
* previously failed with a confusing {@link NoClassDefFoundError} instead of an
|
||||
* actionable message.
|
||||
*/
|
||||
@ClassPathExclusions("spring-security-access-*.jar")
|
||||
public class Gh19441Tests {
|
||||
|
||||
@Test
|
||||
public void enableMethodSecurityWhenAccessModuleAbsentThenContextStartsCleanly() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableMethodSecurityConfig.class);
|
||||
context.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableReactiveMethodSecurityWhenAccessModuleAbsentThenContextStartsCleanly() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableReactiveMethodSecurityConfig.class);
|
||||
context.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableGlobalMethodSecurityWhenProxyModeAndAccessModuleAbsentThenClearException() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableGlobalMethodSecurityProxyConfig.class);
|
||||
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
|
||||
.havingRootCause()
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.withMessageContaining("spring-security-access");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableGlobalMethodSecurityWhenAspectJModeAndAccessModuleAbsentThenClearException() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableGlobalMethodSecurityAspectJConfig.class);
|
||||
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
|
||||
.havingRootCause()
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.withMessageContaining("spring-security-access");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableReactiveMethodSecurityWhenUseAuthorizationManagerFalseAndAccessModuleAbsentThenClearException() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableReactiveMethodSecurityLegacyConfig.class);
|
||||
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
|
||||
.havingRootCause()
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.withMessageContaining("spring-security-access");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableGlobalMethodSecurityWhenAspectJModeAndJsr250EnabledAndConfigurationSubclassedAndAccessModuleAbsentThenClearException() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(EnableGlobalMethodSecurityAspectJJsr250SubclassedConfig.class);
|
||||
assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
|
||||
.havingRootCause()
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.withMessageContaining("spring-security-access");
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
static class EnableMethodSecurityConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableReactiveMethodSecurity
|
||||
static class EnableReactiveMethodSecurityConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||
static class EnableGlobalMethodSecurityProxyConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true, mode = AdviceMode.ASPECTJ)
|
||||
static class EnableGlobalMethodSecurityAspectJConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableReactiveMethodSecurity(useAuthorizationManager = false)
|
||||
static class EnableReactiveMethodSecurityLegacyConfig {
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(jsr250Enabled = true, mode = AdviceMode.ASPECTJ)
|
||||
static class EnableGlobalMethodSecurityAspectJJsr250SubclassedConfig extends GlobalMethodSecurityConfiguration {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+62
@@ -37,6 +37,7 @@ import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.userdetails.PasswordEncodedUser;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
@@ -110,6 +111,21 @@ public class WebSecurityTests {
|
||||
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void ignoringWhenBuilderBeanWithBasePathThenHonorsBasePath() throws Exception {
|
||||
loadConfig(IgnoringBuilderBeanConfig.class);
|
||||
this.request.setServletPath("/spring");
|
||||
this.request.setRequestURI("/spring/path");
|
||||
this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain);
|
||||
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
|
||||
setup();
|
||||
this.request.setServletPath("");
|
||||
this.request.setRequestURI("/path");
|
||||
this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain);
|
||||
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
public void loadConfig(Class<?>... configs) {
|
||||
this.context = new AnnotationConfigWebApplicationContext();
|
||||
this.context.register(configs);
|
||||
@@ -201,6 +217,52 @@ public class WebSecurityTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
static class IgnoringBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
WebSecurityCustomizer webSecurityCustomizer() {
|
||||
return (web) -> web.ignoring().requestMatchers("/path");
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.httpBasic(withDefaults())
|
||||
.authorizeHttpRequests((requests) -> requests
|
||||
.anyRequest().denyAll());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService() {
|
||||
return new InMemoryUserDetailsManager(PasswordEncodedUser.user());
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class PathController {
|
||||
|
||||
@RequestMapping("/path")
|
||||
String path() {
|
||||
return "path";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class RequestRejectedHandlerConfig {
|
||||
|
||||
+43
@@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.users.AuthenticationTestConfiguration;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextChangedListener;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
@@ -153,6 +154,17 @@ public class FormLoginConfigurerTests {
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void loginWhenBuilderBeanWithBasePathThenLoginProcessingUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(FormLoginBuilderBeanConfig.class).autowire();
|
||||
// @formatter:off
|
||||
this.mockMvc.perform(formLogin().user("invalid"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(redirectedUrl("/login?error"));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loginWhenFormLoginConfiguredThenHasDefaultSuccessUrl() throws Exception {
|
||||
this.spring.register(FormLoginConfig.class).autowire();
|
||||
@@ -519,6 +531,37 @@ public class FormLoginConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableWebMvc
|
||||
static class FormLoginBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((requests) -> requests
|
||||
.anyRequest().authenticated())
|
||||
.formLogin(withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService() {
|
||||
return new InMemoryUserDetailsManager(PasswordEncodedUser.user());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class FormLoginInLambdaConfig {
|
||||
|
||||
+35
@@ -35,6 +35,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
import org.springframework.security.core.userdetails.PasswordEncodedUser;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
@@ -127,6 +128,17 @@ public class LogoutConfigurerTests {
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void logoutWhenBuilderBeanWithBasePathThenLogoutUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(LogoutBuilderBeanConfig.class).autowire();
|
||||
// @formatter:off
|
||||
this.mvc.perform(post("/logout").with(csrf()))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(redirectedUrl("/login?logout"));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
// SEC-2311
|
||||
@Test
|
||||
public void logoutWhenGetRequestAndCsrfDisabledThenRedirectsToLogin() throws Exception {
|
||||
@@ -524,6 +536,29 @@ public class LogoutConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class LogoutBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.logout(withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class CsrfDisabledConfig {
|
||||
|
||||
+34
@@ -26,6 +26,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@@ -66,6 +67,16 @@ public class PasswordManagementConfigurerTests {
|
||||
.andExpect(redirectedUrl("/custom-change-password-page"));
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void changePasswordWhenBuilderBeanWithBasePathThenChangePasswordUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(PasswordManagementBuilderBeanConfig.class).autowire();
|
||||
|
||||
this.mvc.perform(get("/.well-known/change-password"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(redirectedUrl("/change-password"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void whenSettingNullChangePasswordPage() {
|
||||
PasswordManagementConfigurer configurer = new PasswordManagementConfigurer();
|
||||
@@ -102,6 +113,29 @@ public class PasswordManagementConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class PasswordManagementBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
return http
|
||||
.passwordManagement(withDefaults())
|
||||
.build();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class PasswordManagementWithCustomChangePasswordPageConfig {
|
||||
|
||||
+41
@@ -34,6 +34,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.test.web.servlet.RequestCacheResultMatcher;
|
||||
@@ -185,6 +186,21 @@ public class RequestCacheConfigurerTests {
|
||||
this.mvc.perform(formLogin(session)).andExpect(redirectedUrl("/"));
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void getWhenBuilderBeanWithBasePathThenSavedRequestMatcherIgnoresBasePath() throws Exception {
|
||||
this.spring.register(RequestCacheBuilderBeanConfig.class, DefaultSecurityConfig.class).autowire();
|
||||
MockHttpServletRequestBuilder request = get("/messages").header(HttpHeaders.ACCEPT, MediaType.TEXT_HTML);
|
||||
// @formatter:off
|
||||
MockHttpSession session = (MockHttpSession) this.mvc.perform(request)
|
||||
.andExpect(redirectedUrl("/login"))
|
||||
.andReturn()
|
||||
.getRequest()
|
||||
.getSession();
|
||||
// @formatter:on
|
||||
this.mvc.perform(formLogin(session)).andExpect(RequestCacheResultMatcher.redirectToCachedRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getWhenBookmarkedRequestIsAllMediaTypeThenPostAuthenticationRemembers() throws Exception {
|
||||
this.spring.register(RequestCacheDefaultsConfig.class, DefaultSecurityConfig.class).autowire();
|
||||
@@ -401,6 +417,31 @@ public class RequestCacheConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class RequestCacheBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((requests) -> requests
|
||||
.anyRequest().authenticated())
|
||||
.formLogin(withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class RequestCacheDisabledConfig {
|
||||
|
||||
+38
@@ -56,6 +56,7 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.cli
|
||||
import org.springframework.security.config.oauth2.client.CommonOAuth2Provider;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.context.DelegatingApplicationListener;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
@@ -440,6 +441,18 @@ public class OAuth2LoginConfigurerTests {
|
||||
then(redirectStrategy).should().sendRedirect(any(), any(), anyString());
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void oauth2LoginWhenBuilderBeanWithBasePathThenLoginProcessingUrlIgnoresBasePath() throws Exception {
|
||||
loadConfig(OAuth2LoginBuilderBeanConfig.class);
|
||||
String requestUri = "/login/oauth2/code/google";
|
||||
this.request = get(requestUri).build();
|
||||
this.request.setParameter("code", "code123");
|
||||
this.request.setParameter("state", "state123");
|
||||
this.springSecurityFilterChain.doFilter(this.request, this.response, this.filterChain);
|
||||
assertThat(this.response.getRedirectedUrl()).endsWith("/login?error");
|
||||
}
|
||||
|
||||
// gh-5347
|
||||
@Test
|
||||
public void oauth2LoginWithOneClientConfiguredThenRedirectForAuthorization() throws Exception {
|
||||
@@ -788,6 +801,31 @@ public class OAuth2LoginConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class OAuth2LoginBuilderBeanConfig extends CommonSecurityFilterChainConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.oauth2Login((login) -> login
|
||||
.clientRegistrationRepository(
|
||||
new InMemoryClientRegistrationRepository(GOOGLE_CLIENT_REGISTRATION)));
|
||||
// @formatter:on
|
||||
return super.configureFilterChain(http);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
static class OAuth2LoginConfigFormLogin extends CommonSecurityFilterChainConfig {
|
||||
|
||||
+49
@@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.userdetails.PasswordEncodedUser;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
@@ -98,6 +99,19 @@ public class OneTimeTokenLoginConfigurerTests {
|
||||
.andExpectAll(status().isFound(), redirectedUrl("/"), authenticated());
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
void oneTimeTokenWhenBuilderBeanWithBasePathThenGenerateAndLoginUrlsIgnoreBasePath() throws Exception {
|
||||
this.spring.register(OneTimeTokenBuilderBeanConfig.class).autowire();
|
||||
this.mvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
|
||||
.andExpectAll(status().isFound(), redirectedUrl("/login/ott"));
|
||||
|
||||
String token = getLastToken().getTokenValue();
|
||||
|
||||
this.mvc.perform(post("/login/ott").param("token", token).with(csrf()))
|
||||
.andExpectAll(status().isFound(), redirectedUrl("/"), authenticated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneTimeTokenWhenDifferentAuthenticationUrlsThenCanAuthenticate() throws Exception {
|
||||
this.spring.register(OneTimeTokenDifferentUrlsConfig.class).autowire();
|
||||
@@ -296,6 +310,41 @@ public class OneTimeTokenLoginConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableWebSecurity
|
||||
@Import(UserDetailsServiceConfig.class)
|
||||
static class OneTimeTokenBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||
OneTimeTokenGenerationSuccessHandler ottSuccessHandler) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oneTimeTokenLogin((ott) -> ott
|
||||
.tokenGenerationSuccessHandler(ottSuccessHandler)
|
||||
);
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
TestOneTimeTokenGenerationSuccessHandler ottSuccessHandler() {
|
||||
return new TestOneTimeTokenGenerationSuccessHandler();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableWebSecurity
|
||||
@Import(UserDetailsServiceConfig.class)
|
||||
|
||||
+36
@@ -57,6 +57,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -244,6 +245,14 @@ public class Saml2LoginConfigurerTests {
|
||||
performSaml2Login("ROLE_AUTH_MANAGER");
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void saml2LoginWhenBuilderBeanWithBasePathThenAuthenticateUriIgnoresBasePath() throws Exception {
|
||||
this.spring.register(Saml2LoginBuilderBeanConfig.class).autowire();
|
||||
MvcResult result = this.mvc.perform(get("/saml2/authenticate/registration-id")).andReturn();
|
||||
assertThat(result.getResponse().getRedirectedUrl()).contains("SAMLRequest");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authenticationRequestWhenAuthenticationRequestResolverBeanThenUses() throws Exception {
|
||||
this.spring.register(CustomAuthenticationRequestResolverBean.class).autowire();
|
||||
@@ -522,6 +531,33 @@ public class Saml2LoginConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(Saml2LoginConfigBeans.class)
|
||||
static class Saml2LoginBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authz) -> authz
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.saml2Login(Customizer.withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(Saml2LoginConfigBeans.class)
|
||||
|
||||
+38
@@ -49,6 +49,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
import org.springframework.security.saml2.core.Saml2Utils;
|
||||
@@ -196,6 +197,17 @@ public class Saml2LogoutConfigurerTests {
|
||||
verify(logoutHandler).logout(any(), any(), any());
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
public void saml2LogoutWhenBuilderBeanWithBasePathThenLogoutUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(Saml2LogoutBuilderBeanConfig.class).autowire();
|
||||
MvcResult result = this.mvc.perform(post("/logout").with(authentication(this.user)).with(csrf()))
|
||||
.andExpect(status().isFound())
|
||||
.andReturn();
|
||||
String location = result.getResponse().getHeader("Location");
|
||||
assertThat(location).startsWith("https://ap.example.org/logout/saml2/request");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saml2LogoutWhenUnauthenticatedThenEntryPoint() throws Exception {
|
||||
this.spring.register(Saml2LogoutDefaultsConfig.class).autowire();
|
||||
@@ -577,6 +589,32 @@ public class Saml2LogoutConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(Saml2LoginConfigBeans.class)
|
||||
static class Saml2LogoutBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain web(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
|
||||
.saml2Login(withDefaults())
|
||||
.saml2Logout(withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@Import(Saml2LoginConfigBeans.class)
|
||||
|
||||
+35
@@ -30,6 +30,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.config.web.PathPatternRequestMatcherBuilderFactoryBean;
|
||||
import org.springframework.security.saml2.provider.service.metadata.OpenSaml5MetadataResolver;
|
||||
import org.springframework.security.saml2.provider.service.metadata.RequestMatcherMetadataResponseResolver;
|
||||
import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponse;
|
||||
@@ -124,6 +125,15 @@ public class Saml2MetadataConfigurerTests {
|
||||
this.mvc.perform(get("/saml2/metadata")).andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@Test
|
||||
void saml2MetadataWhenBuilderBeanWithBasePathThenMetadataUrlIgnoresBasePath() throws Exception {
|
||||
this.spring.register(MetadataBuilderBeanConfig.class).autowire();
|
||||
this.mvc.perform(get("/saml/metadata"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("md:EntityDescriptor")));
|
||||
}
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@Import(RelyingPartyRegistrationConfig.class)
|
||||
@@ -141,6 +151,31 @@ public class Saml2MetadataConfigurerTests {
|
||||
|
||||
}
|
||||
|
||||
// gh-19128
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@Import(RelyingPartyRegistrationConfig.class)
|
||||
static class MetadataBuilderBeanConfig {
|
||||
|
||||
@Bean
|
||||
PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() {
|
||||
PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean();
|
||||
bean.setBasePath("/spring");
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filters(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
|
||||
.saml2Metadata((saml2) -> saml2.metadataUrl("/saml/metadata"));
|
||||
return http.build();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@Import(RelyingPartyRegistrationConfig.class)
|
||||
|
||||
+8
-1
@@ -11,13 +11,16 @@ dependencies {
|
||||
def springFrameworkVersion = project.property("springFrameworkVersion")
|
||||
def reactorVersion = project.property("reactorVersion")
|
||||
def springDataVersion = project.property("springDataVersion")
|
||||
def micrometerVersion = project.property("micrometerVersion")
|
||||
api platform("org.springframework:spring-framework-bom:$springFrameworkVersion")
|
||||
api platform("io.projectreactor:reactor-bom:$reactorVersion")
|
||||
api platform("org.springframework.data:spring-data-bom:$springDataVersion")
|
||||
api platform("io.micrometer:micrometer-bom:$micrometerVersion")
|
||||
} else {
|
||||
api platform(libs.org.springframework.spring.framework.bom)
|
||||
api platform(libs.io.projectreactor.reactor.bom)
|
||||
api platform(libs.org.springframework.data.spring.data.bom)
|
||||
api platform(libs.io.micrometer.micrometer.bom)
|
||||
}
|
||||
api platform(libs.io.rsocket.rsocket.bom)
|
||||
api platform(libs.org.junit.junit.bom)
|
||||
@@ -72,7 +75,11 @@ dependencies {
|
||||
api libs.org.skyscreamer.jsonassert
|
||||
api libs.org.slf4j.log4j.over.slf4j
|
||||
api libs.org.slf4j.slf4j.api
|
||||
api libs.org.springframework.ldap.spring.ldap.core
|
||||
if (project.hasProperty("isOverrideVersionCatalog") && project.hasProperty("springLdapVersion")) {
|
||||
api "org.springframework.ldap:spring-ldap-core:${springLdapVersion}"
|
||||
} else {
|
||||
api libs.org.springframework.ldap.spring.ldap.core
|
||||
}
|
||||
api libs.org.synchronoss.cloud.nio.multipart.parser
|
||||
api libs.org.apache.maven.resolver.maven.resolver.connector.basic
|
||||
api libs.org.apache.maven.resolver.maven.resolver.impl
|
||||
|
||||
@@ -23,10 +23,28 @@ When using a proxy server, it is important to ensure that you have configured yo
|
||||
For example, many applications have a load balancer that responds to request for `\https://example.com/` by forwarding the request to an application server at `\https://192.168.0.107`
|
||||
Without proper configuration, the application server can not know that the load balancer exists and treats the request as though `\https://192.168.0.107:8080` was requested by the client.
|
||||
|
||||
To fix this, you can use https://tools.ietf.org/html/rfc7239[RFC 7239] to specify that a load balancer is being used.
|
||||
To make the application aware of this, you need to configure your application server to be aware of the X-Forwarded headers.
|
||||
To fix this, the proxy needs to pass on the details of the original request, and the application needs to be configured to use them.
|
||||
Two kinds of headers are used for this, and it is important to know which of them applies in your deployment:
|
||||
|
||||
* The standard `Forwarded` header, defined by https://tools.ietf.org/html/rfc7239[RFC 7239], which carries the original host, protocol, and client in a single header.
|
||||
* The non-standard `X-Forwarded-*` headers, such as `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-For`, which predate RFC 7239.
|
||||
|
||||
Most proxies still send the `X-Forwarded-*` headers rather than the standard `Forwarded` header, while Spring Framework and servers such as Reactor Netty and Jetty understand both.
|
||||
Do not assume that only one of them is in use.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
Both kinds of headers are supplied by the client unless a proxy overwrites them, so an application that trusts them without a trusted proxy in front of it can be made to believe a request arrived over a different host, protocol, or client address than it really did.
|
||||
====
|
||||
|
||||
For this reason, the proxy at the edge of your network must be configured to remove or overwrite any forwarded headers that arrive from the outside, for _both_ kinds of headers.
|
||||
Dropping only the `Forwarded` header while passing through `X-Forwarded-*` (or the reverse) leaves the application open to the same spoofing through the other set.
|
||||
Only headers added by a proxy you control should reach the application.
|
||||
|
||||
Once untrusted values are handled at the edge, the application server can be configured to apply the headers.
|
||||
For example, Tomcat uses https://tomcat.apache.org/tomcat-10.1-doc/api/org/apache/catalina/valves/RemoteIpValve.html[`RemoteIpValve`] and Jetty uses https://eclipse.dev/jetty/javadoc/jetty-11/org/eclipse/jetty/server/ForwardedRequestCustomizer.html[`ForwardedRequestCustomizer`].
|
||||
Alternatively, Spring users can use https://docs.spring.io/spring-framework/reference/web/webmvc/filters.html#filters-forwarded-headers[`ForwardedHeaderFilter`] with the Servlet stack or https://docs.spring.io/spring-framework/reference/web/webflux/reactive-spring.html#webflux-forwarded-headers[`ForwardedHeaderTransformer`] with the Reactive stack.
|
||||
Both handle the `Forwarded` header and the `X-Forwarded-*` headers, and both can be configured to remove the headers instead of applying them, which is useful when the application is not behind a proxy.
|
||||
|
||||
Spring Boot users can use the `server.forward-headers-strategy` property to configure the application.
|
||||
See the https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto.webserver.use-behind-a-proxy-server[Spring Boot documentation] for further details.
|
||||
|
||||
@@ -132,4 +132,11 @@ authenticationConverter.setBearerTokenResolver(myBearerTokenResolver)
|
||||
authenticationConverter.setAuthenticationDetailsSource(myAuthenticationDetailsSource)
|
||||
val filter = BearerTokenAuthenticationFilter(authenticationManager, authenticationConverter)
|
||||
----
|
||||
|
||||
== `NimbusJwtDecoder`'s Default Connect and Read Timeouts Are Now 30 Seconds
|
||||
|
||||
`NimbusJwtDecoder`'s default `RestOperations`, used to fetch a JWK Set when no `RestOperations` is otherwise configured, previously used a 500 millisecond connect and read timeout.
|
||||
This value was too short for many deployments and is now 30 seconds, aligning with the connect and read timeouts already used elsewhere in the OAuth2 Client and Resource Server support, such as xref:servlet/oauth2/client/index.adoc[`ClientRegistrations`].
|
||||
|
||||
If your application relies on the previous, shorter timeout -- for example, expecting a fast failure when the authorization server is unreachable -- you can restore it either by setting the JDK's `sun.net.client.defaultConnectTimeout` and `sun.net.client.defaultReadTimeout` system properties (in milliseconds), or by providing your own `RestOperations`, as described in xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-timeouts[Configuring Timeouts].
|
||||
======
|
||||
|
||||
@@ -81,6 +81,12 @@ Xml::
|
||||
|
||||
and no authorization changes are necessary since it simply adjusts the `LogoutFilter`.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
The URI passed to `logoutUrl` (and to `logoutSuccessUrl`) is matched and redirected to literally.
|
||||
If you want either URI to live under your application servlet's base path, include that prefix explicitly — for example, `logoutUrl("/api/logout")`.
|
||||
====
|
||||
|
||||
[[permit-logout-endpoints]]
|
||||
However, if you stand up your own logout success endpoint (or in a rare case, <<creating-custom-logout-endpoint, your own logout endpoint>>), say using {spring-framework-reference-url}web.html#spring-web[Spring MVC], you will need to permit it in Spring Security.
|
||||
This is because Spring MVC processes your request after Spring Security does.
|
||||
|
||||
@@ -252,6 +252,13 @@ class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSucc
|
||||
----
|
||||
======
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
The URI passed to `generateTokenUrl` is matched literally.
|
||||
If you want it to live under your application servlet's base path, include that prefix explicitly — for example, `generateTokenUrl("/api/ott/generate")`.
|
||||
The same applies to `loginProcessingUrl` and `tokenGeneratingUrl` elsewhere on this page.
|
||||
====
|
||||
|
||||
[[changing-submit-page-url]]
|
||||
== Changing the Default Submit Page URL
|
||||
|
||||
|
||||
@@ -155,6 +155,15 @@ open fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
----
|
||||
======
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
The URI passed to `loginPage` (and to `loginProcessingUrl`, if set separately) is matched and redirected to literally.
|
||||
If your custom login page is served by an application servlet mapped under a base path, include that prefix in the URI you pass to the DSL.
|
||||
For example, if your dispatcher is at `/api/*`:
|
||||
|
||||
include-code::./FormLoginServletPathConfiguration[tag=loginPage,indent=0]
|
||||
====
|
||||
|
||||
[[servlet-authentication-form-custom-html]]
|
||||
When the login page is specified in the Spring Security configuration, you are responsible for rendering the page.
|
||||
// FIXME: default login page rendered by Spring Security
|
||||
|
||||
@@ -642,6 +642,8 @@ This is because Spring Security requires all URIs to be absolute (minus the cont
|
||||
There are several other components that create request matchers for you like {spring-boot-api-url}org/springframework/boot/security/autoconfigure/web/servlet/PathRequest.html[`PathRequest#toStaticResources#atCommonLocations`]
|
||||
=====
|
||||
|
||||
If most of your authorization rules sit under the same servlet path, you can xref:servlet/integrations/mvc.adoc#mvc-requestmatcher[publish a `PathPatternRequestMatcher.Builder` bean] with that base path; Spring Security then applies it to the string overloads of `authorizeHttpRequests((authorize) -> authorize.requestMatchers(...))`, `HttpSecurity#securityMatcher(...)` / `#securityMatchers(...)`, and `WebSecurityCustomizer#ignoring().requestMatchers(...)`.
|
||||
|
||||
[[match-by-custom]]
|
||||
=== Using a Custom Matcher
|
||||
|
||||
|
||||
@@ -944,5 +944,52 @@ For MAC-based algorithms (such as `HS256`, `HS384`, or `HS512`), the `client-sec
|
||||
If more than one `ClientRegistration` is configured for OpenID Connect 1.0 Authentication, the JWS algorithm resolver may evaluate the provided `ClientRegistration` to determine which algorithm to return.
|
||||
====
|
||||
|
||||
[[oauth2login-advanced-idtoken-decoder-factory]]
|
||||
== Providing a Custom JwtDecoderFactory
|
||||
|
||||
`OidcIdTokenDecoderFactory` is deliberately narrow: it exists to make it easy to support multiple `ClientRegistration` instances, each potentially needing a different `JwtDecoder`, and its configuration surface is limited to what most applications need for that purpose, such as the JWS algorithm resolver shown above.
|
||||
|
||||
If your application needs deeper control over how the ID Token's `JwtDecoder` is constructed -- for example, providing your own `RestOperations` -- you can instead provide your own `JwtDecoderFactory<ClientRegistration>` `@Bean`.
|
||||
Because OAuth2 Login does not use a resource server `JwtDecoder` bean for this purpose, this is the supported way to reach the same level of control that `NimbusJwtDecoder`'s builder already offers, without introducing additional configuration properties on `OidcIdTokenDecoderFactory` itself.
|
||||
|
||||
For a single `ClientRegistration`, this can be as simple as:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Bean
|
||||
JwtDecoderFactory<ClientRegistration> idTokenDecoderFactory(RestOperations rest) {
|
||||
return (clientRegistration) -> {
|
||||
String issuerUri = clientRegistration.getProviderDetails().getIssuerUri();
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).restOperations(rest).build();
|
||||
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(new OidcIdTokenValidator(clientRegistration)));
|
||||
decoder.setClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverter());
|
||||
return decoder;
|
||||
};
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Bean
|
||||
fun idTokenDecoderFactory(rest: RestOperations): JwtDecoderFactory<ClientRegistration> {
|
||||
return JwtDecoderFactory { clientRegistration ->
|
||||
val issuerUri = clientRegistration.providerDetails.issuerUri
|
||||
val decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).restOperations(rest).build()
|
||||
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(OidcIdTokenValidator(clientRegistration)))
|
||||
decoder.setClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverter())
|
||||
decoder
|
||||
}
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
If you have multiple `ClientRegistration` instances and want to avoid rebuilding a `JwtDecoder` on every request, cache the decoder per registration, for example by annotating the factory method with `@Cacheable` or by keying a `Map` on `ClientRegistration#getRegistrationId()`.
|
||||
|
||||
[[oauth2login-advanced-oidc-logout]]
|
||||
Then, you can proceed to configure xref:servlet/oauth2/login/logout.adoc[logout]
|
||||
|
||||
@@ -1582,6 +1582,7 @@ fun jwtDecoder(): JwtDecoder {
|
||||
== Configuring Timeouts
|
||||
|
||||
By default, Resource Server uses connection and socket timeouts of 30 seconds each for coordinating with the authorization server.
|
||||
You can override these defaults without changing any code by setting the JDK's `sun.net.client.defaultConnectTimeout` and `sun.net.client.defaultReadTimeout` system properties (in milliseconds).
|
||||
|
||||
This may be too short in some scenarios.
|
||||
Further, it doesn't take into account more sophisticated patterns like back-off and discovery.
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"antora": "3.2.0-alpha.11",
|
||||
"antora": "3.2.0-alpha.12",
|
||||
"@antora/atlas-extension": "1.0.0-alpha.5",
|
||||
"@antora/collector-extension": "1.0.3",
|
||||
"@asciidoctor/tabs": "1.0.0-beta.6",
|
||||
"@springio/antora-extensions": "1.14.11",
|
||||
"@springio/antora-extensions": "1.14.12",
|
||||
"@springio/asciidoctor-extensions": "1.0.0-alpha.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ plugins {
|
||||
apply plugin: 'io.spring.convention.docs'
|
||||
apply plugin: 'java'
|
||||
|
||||
node {
|
||||
version = "24.15.0"
|
||||
}
|
||||
|
||||
antora {
|
||||
options = [clean: true, fetch: !project.gradle.startParameter.offline, stacktrace: true]
|
||||
environment = [
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.docs.servlet.authentication.passwords.servletauthenticationform;
|
||||
|
||||
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.web.SecurityFilterChain;
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class FormLoginServletPathConfiguration {
|
||||
|
||||
// tag::loginPage[]
|
||||
@Bean
|
||||
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin((form) -> form
|
||||
.loginPage("/api/login")
|
||||
.loginProcessingUrl("/api/login")
|
||||
);
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
// end::loginPage[]
|
||||
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package org.springframework.security.kt.docs.servlet.authentication.passwords.servletauthenticationform
|
||||
|
||||
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.web.SecurityFilterChain
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
class FormLoginServletPathConfiguration {
|
||||
|
||||
// tag::loginPage[]
|
||||
@Bean
|
||||
fun springSecurity(http: HttpSecurity): SecurityFilterChain {
|
||||
// @formatter:off
|
||||
http {
|
||||
authorizeHttpRequests {
|
||||
authorize(anyRequest, authenticated)
|
||||
}
|
||||
formLogin {
|
||||
loginPage = "/api/login"
|
||||
loginProcessingUrl = "/api/login"
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
// @formatter:on
|
||||
}
|
||||
// end::loginPage[]
|
||||
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
springBootVersion=4.0.0-SNAPSHOT
|
||||
version=7.0.5-SNAPSHOT
|
||||
version=7.0.7-SNAPSHOT
|
||||
samplesBranch=main
|
||||
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.parallel=true
|
||||
|
||||
+13
-11
@@ -1,22 +1,23 @@
|
||||
[versions]
|
||||
com-squareup-okhttp3 = "3.14.9"
|
||||
io-micrometer = "1.16.6"
|
||||
io-rsocket = "1.1.5"
|
||||
io-spring-javaformat = "0.0.47"
|
||||
io-spring-nohttp = "0.0.11"
|
||||
jakarta-websocket = "2.2.0"
|
||||
org-apache-maven-resolver = "1.9.27"
|
||||
org-aspectj = "1.9.25.1"
|
||||
org-bouncycastle = "1.80"
|
||||
org-bouncycastle = "1.80.2"
|
||||
org-eclipse-jetty = "11.0.26"
|
||||
org-jetbrains-kotlin = "2.2.21"
|
||||
org-jetbrains-kotlinx = "1.10.2"
|
||||
org-mockito = "5.17.0"
|
||||
org-opensaml5 = "5.1.6"
|
||||
org-springframework = "7.0.7"
|
||||
org-springframework = "7.0.8"
|
||||
com-password4j = "1.8.4"
|
||||
|
||||
[libraries]
|
||||
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.32"
|
||||
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.34"
|
||||
com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.20.2"
|
||||
com-google-inject-guice = "com.google.inject:guice:3.0"
|
||||
com-netflix-nebula-nebula-project-plugin = "com.netflix.nebula:nebula-project-plugin:8.2.0"
|
||||
@@ -28,9 +29,10 @@ com-unboundid-unboundid-ldapsdk = "com.unboundid:unboundid-ldapsdk:7.0.4"
|
||||
com-jayway-jsonpath-json-path = "com.jayway.jsonpath:json-path:2.9.0"
|
||||
commons-collections = "commons-collections:commons-collections:3.2.2"
|
||||
io-micrometer-context-propagation = "io.micrometer:context-propagation:1.1.4"
|
||||
io-micrometer-micrometer-observation = "io.micrometer:micrometer-observation:1.14.14"
|
||||
io-mockk = "io.mockk:mockk:1.14.9"
|
||||
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.5"
|
||||
io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" }
|
||||
io-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
|
||||
io-mockk = "io.mockk:mockk:1.14.11"
|
||||
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.6"
|
||||
io-rsocket-rsocket-bom = { module = "io.rsocket:rsocket-bom", version.ref = "io-rsocket" }
|
||||
io-spring-javaformat-spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "io-spring-javaformat" }
|
||||
io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" }
|
||||
@@ -51,7 +53,7 @@ net-sourceforge-htmlunit = "net.sourceforge.htmlunit:htmlunit:2.70.0"
|
||||
org-htmlunit-htmlunit = "org.htmlunit:htmlunit:4.11.1"
|
||||
org-apache-httpcomponents-httpclient = "org.apache.httpcomponents.client5:httpclient5:5.5.2"
|
||||
org-apache-kerby-simplekdc='org.apache.kerby:kerb-simplekdc:2.1.1'
|
||||
org-apache-maven-maven-resolver-provider = "org.apache.maven:maven-resolver-provider:3.9.15"
|
||||
org-apache-maven-maven-resolver-provider = "org.apache.maven:maven-resolver-provider:3.9.16"
|
||||
org-apache-maven-resolver-maven-resolver-connector-basic = { module = "org.apache.maven.resolver:maven-resolver-connector-basic", version.ref = "org-apache-maven-resolver" }
|
||||
org-apache-maven-resolver-maven-resolver-impl = { module = "org.apache.maven.resolver:maven-resolver-impl", version.ref = "org-apache-maven-resolver" }
|
||||
org-apache-maven-resolver-maven-resolver-transport-http = { module = "org.apache.maven.resolver:maven-resolver-transport-http", version.ref = "org-apache-maven-resolver" }
|
||||
@@ -80,9 +82,9 @@ org-seleniumhq-selenium-selenium-java = "org.seleniumhq.selenium:selenium-java:4
|
||||
org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-support:3.141.59"
|
||||
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
|
||||
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
|
||||
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.17"
|
||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.5"
|
||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.3"
|
||||
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18"
|
||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.6"
|
||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.4"
|
||||
org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" }
|
||||
org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"
|
||||
tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.0.4"
|
||||
@@ -102,7 +104,7 @@ org-sonarsource-scanner-gradle-sonarqube-gradle-plugin = "org.sonarsource.scanne
|
||||
org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1"
|
||||
|
||||
spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.6'
|
||||
webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.31.3.RELEASE'
|
||||
webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.31.6.RELEASE'
|
||||
com-password4j-password4j = { module = "com.password4j:password4j", version.ref = "com-password4j" }
|
||||
|
||||
[plugins]
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=f1771298a70f6db5a29daf62378c4e18a17fc33c9ba6b14362e0cdf40610380d
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
|
||||
distributionSha256Sum=6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
+2
-1
@@ -489,7 +489,8 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
|
||||
registeredClient);
|
||||
if (error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST)
|
||||
&& (parameterName.equals(OAuth2ParameterNames.CLIENT_ID)
|
||||
|| parameterName.equals(OAuth2ParameterNames.STATE))) {
|
||||
|| parameterName.equals(OAuth2ParameterNames.STATE)
|
||||
|| parameterName.equals(OAuth2ParameterNames.REQUEST_URI))) {
|
||||
redirectUri = null; // Prevent redirects
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -293,8 +293,10 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationValidator
|
||||
String redirectUri = StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())
|
||||
? authorizationCodeRequestAuthentication.getRedirectUri()
|
||||
: registeredClient.getRedirectUris().iterator().next();
|
||||
if (error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST)
|
||||
&& parameterName.equals(OAuth2ParameterNames.REDIRECT_URI)) {
|
||||
if ((error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST)
|
||||
|| error.getErrorCode().equals(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT))
|
||||
&& (parameterName.equals(OAuth2ParameterNames.CLIENT_ID)
|
||||
|| parameterName.equals(OAuth2ParameterNames.REDIRECT_URI))) {
|
||||
redirectUri = null; // Prevent redirects
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -50,9 +50,9 @@ import org.springframework.util.CollectionUtils;
|
||||
* <p>
|
||||
* Each validated field is backed by two public constants:
|
||||
* <ul>
|
||||
* <li>{@code DEFAULT_*_VALIDATOR} — strict validation that rejects unsafe values. This is
|
||||
* the default behavior and may reject input that was previously accepted.</li>
|
||||
* <li>{@code SIMPLE_*_VALIDATOR} — lenient validation preserving the behavior from prior
|
||||
* <li>{@code DEFAULT_*_VALIDATOR} -- strict validation that rejects unsafe values. This
|
||||
* is the default behavior and may reject input that was previously accepted.</li>
|
||||
* <li>{@code SIMPLE_*_VALIDATOR} -- lenient validation preserving the behavior from prior
|
||||
* releases. Use only when strictly required for backward compatibility and with full
|
||||
* understanding that it may accept values that enable attacks against the authorization
|
||||
* server.</li>
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ import org.springframework.security.crypto.keygen.StringKeyGenerator;
|
||||
* Requests.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @author Andrey Litvitski
|
||||
* @since 7.0
|
||||
*/
|
||||
final class OAuth2PushedAuthorizationRequestUri {
|
||||
@@ -60,7 +61,7 @@ final class OAuth2PushedAuthorizationRequestUri {
|
||||
|
||||
static OAuth2PushedAuthorizationRequestUri parse(String requestUri) {
|
||||
int stateStartIndex = REQUEST_URI_PREFIX.length();
|
||||
int expiresAtStartIndex = requestUri.indexOf(REQUEST_URI_DELIMITER) + REQUEST_URI_DELIMITER.length();
|
||||
int expiresAtStartIndex = requestUri.lastIndexOf(REQUEST_URI_DELIMITER) + REQUEST_URI_DELIMITER.length();
|
||||
OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = new OAuth2PushedAuthorizationRequestUri();
|
||||
pushedAuthorizationRequestUri.requestUri = requestUri;
|
||||
pushedAuthorizationRequestUri.state = requestUri.substring(stateStartIndex);
|
||||
|
||||
+3
-3
@@ -51,9 +51,9 @@ import org.springframework.util.CollectionUtils;
|
||||
* <p>
|
||||
* Each validated field is backed by two public constants:
|
||||
* <ul>
|
||||
* <li>{@code DEFAULT_*_VALIDATOR} — strict validation that rejects unsafe values. This is
|
||||
* the default behavior and may reject input that was previously accepted.</li>
|
||||
* <li>{@code SIMPLE_*_VALIDATOR} — lenient validation preserving the behavior from prior
|
||||
* <li>{@code DEFAULT_*_VALIDATOR} -- strict validation that rejects unsafe values. This
|
||||
* is the default behavior and may reject input that was previously accepted.</li>
|
||||
* <li>{@code SIMPLE_*_VALIDATOR} -- lenient validation preserving the behavior from prior
|
||||
* releases. Use only when strictly required for backward compatibility and with full
|
||||
* understanding that it may accept values that enable attacks against the authorization
|
||||
* server.</li>
|
||||
|
||||
+43
-29
@@ -123,46 +123,60 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationConverter impleme
|
||||
principal = ANONYMOUS_AUTHENTICATION;
|
||||
}
|
||||
|
||||
// redirect_uri (OPTIONAL)
|
||||
String redirectUri = parameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
|
||||
if (StringUtils.hasText(redirectUri) && parameters.get(OAuth2ParameterNames.REDIRECT_URI).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI);
|
||||
String redirectUri = null;
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// redirect_uri (OPTIONAL)
|
||||
redirectUri = parameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
|
||||
if (StringUtils.hasText(redirectUri) && parameters.get(OAuth2ParameterNames.REDIRECT_URI).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI);
|
||||
}
|
||||
}
|
||||
|
||||
// scope (OPTIONAL)
|
||||
Set<String> scopes = null;
|
||||
String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
|
||||
if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.SCOPE);
|
||||
}
|
||||
if (StringUtils.hasText(scope)) {
|
||||
scopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// scope (OPTIONAL)
|
||||
String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
|
||||
if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.SCOPE);
|
||||
}
|
||||
if (StringUtils.hasText(scope)) {
|
||||
scopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
|
||||
}
|
||||
}
|
||||
|
||||
// state (RECOMMENDED)
|
||||
String state = parameters.getFirst(OAuth2ParameterNames.STATE);
|
||||
if (StringUtils.hasText(state) && parameters.get(OAuth2ParameterNames.STATE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
|
||||
String state = null;
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// state (RECOMMENDED)
|
||||
state = parameters.getFirst(OAuth2ParameterNames.STATE);
|
||||
if (StringUtils.hasText(state) && parameters.get(OAuth2ParameterNames.STATE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
|
||||
}
|
||||
}
|
||||
|
||||
// code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE)
|
||||
String codeChallenge = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE);
|
||||
if (StringUtils.hasText(codeChallenge) && parameters.get(PkceParameterNames.CODE_CHALLENGE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI);
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE)
|
||||
String codeChallenge = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE);
|
||||
if (StringUtils.hasText(codeChallenge) && parameters.get(PkceParameterNames.CODE_CHALLENGE).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI);
|
||||
}
|
||||
}
|
||||
|
||||
// code_challenge_method (OPTIONAL for public clients) - RFC 7636 (PKCE)
|
||||
String codeChallengeMethod = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE_METHOD);
|
||||
if (StringUtils.hasText(codeChallengeMethod)
|
||||
&& parameters.get(PkceParameterNames.CODE_CHALLENGE_METHOD).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI);
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// code_challenge_method (OPTIONAL for public clients) - RFC 7636 (PKCE)
|
||||
String codeChallengeMethod = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE_METHOD);
|
||||
if (StringUtils.hasText(codeChallengeMethod)
|
||||
&& parameters.get(PkceParameterNames.CODE_CHALLENGE_METHOD).size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI);
|
||||
}
|
||||
}
|
||||
|
||||
// prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request)
|
||||
if (!CollectionUtils.isEmpty(scopes) && scopes.contains(OidcScopes.OPENID)) {
|
||||
String prompt = parameters.getFirst("prompt");
|
||||
if (StringUtils.hasText(prompt) && parameters.get("prompt").size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "prompt");
|
||||
if (!StringUtils.hasText(requestUri)) {
|
||||
// prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request)
|
||||
if (!CollectionUtils.isEmpty(scopes) && scopes.contains(OidcScopes.OPENID)) {
|
||||
String prompt = parameters.getFirst("prompt");
|
||||
if (StringUtils.hasText(prompt) && parameters.get("prompt").size() != 1) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "prompt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -311,7 +311,7 @@ public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests {
|
||||
assertThatExceptionOfType(OAuth2AuthorizationCodeRequestAuthenticationException.class)
|
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
|
||||
.satisfies((ex) -> assertAuthenticationException(ex, OAuth2ErrorCodes.UNAUTHORIZED_CLIENT,
|
||||
OAuth2ParameterNames.CLIENT_ID, authentication.getRedirectUri()));
|
||||
OAuth2ParameterNames.CLIENT_ID, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ public class OAuth2PushedAuthorizationRequestAuthenticationProviderTests {
|
||||
assertThatExceptionOfType(OAuth2AuthorizationCodeRequestAuthenticationException.class)
|
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
|
||||
.satisfies((ex) -> assertAuthenticationException(ex, OAuth2ErrorCodes.UNAUTHORIZED_CLIENT,
|
||||
OAuth2ParameterNames.CLIENT_ID, authentication.getRedirectUri()));
|
||||
OAuth2ParameterNames.CLIENT_ID, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.oauth2.server.authorization.authentication;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link OAuth2PushedAuthorizationRequestUri}.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @author Andrey Litvitski
|
||||
*/
|
||||
public class OAuth2PushedAuthorizationRequestUriTests {
|
||||
|
||||
@Test
|
||||
public void parseWhenValidRequestUriThenReturnsExpectedValues() {
|
||||
String state = "abcXYZ123-abcXYZ123";
|
||||
long epochMilli = 1700000000000L;
|
||||
String requestUri = "urn:ietf:params:oauth:request_uri:" + state + "___" + epochMilli;
|
||||
|
||||
OAuth2PushedAuthorizationRequestUri parsed = OAuth2PushedAuthorizationRequestUri.parse(requestUri);
|
||||
|
||||
assertThat(parsed.getRequestUri()).isEqualTo(requestUri);
|
||||
assertThat(parsed.getState()).isEqualTo(state + "___" + epochMilli);
|
||||
assertThat(parsed.getExpiresAt()).isEqualTo(Instant.ofEpochMilli(epochMilli));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createWhenParsedThenReturnsEquivalentValues() {
|
||||
Instant expiresAt = Instant.ofEpochMilli(1700000000000L);
|
||||
|
||||
OAuth2PushedAuthorizationRequestUri created = OAuth2PushedAuthorizationRequestUri.create(expiresAt);
|
||||
OAuth2PushedAuthorizationRequestUri parsed = OAuth2PushedAuthorizationRequestUri.parse(created.getRequestUri());
|
||||
|
||||
assertThat(parsed.getRequestUri()).isEqualTo(created.getRequestUri());
|
||||
assertThat(parsed.getState()).isEqualTo(created.getState());
|
||||
assertThat(parsed.getExpiresAt()).isEqualTo(created.getExpiresAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseWhenStateContainsDelimiterThenParsesSuccessfully() {
|
||||
String state = "xXMGJTZwzXIFL8i_DFu_EM8IeWC___frCWjpiF2q-xs=";
|
||||
long epochMillis = 1781670640281L;
|
||||
String requestUri = "urn:ietf:params:oauth:request_uri:" + state + "___" + epochMillis;
|
||||
|
||||
OAuth2PushedAuthorizationRequestUri parsedUri = OAuth2PushedAuthorizationRequestUri.parse(requestUri);
|
||||
|
||||
assertThat(parsedUri.getRequestUri()).isEqualTo(requestUri);
|
||||
assertThat(parsedUri.getState()).isEqualTo(state + "___" + epochMillis);
|
||||
assertThat(parsedUri.getExpiresAt()).isEqualTo(Instant.ofEpochMilli(epochMillis));
|
||||
}
|
||||
|
||||
}
|
||||
+23
-4
@@ -66,11 +66,9 @@ final class JwtDecoderProviderConfigurationUtils {
|
||||
private static final RestTemplate rest = new RestTemplate();
|
||||
|
||||
static {
|
||||
int connectTimeout = Integer.parseInt(System.getProperty("sun.net.client.defaultConnectTimeout", "30000"));
|
||||
int readTimeout = Integer.parseInt(System.getProperty("sun.net.client.defaultReadTimeout", "30000"));
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(connectTimeout);
|
||||
requestFactory.setReadTimeout(readTimeout);
|
||||
requestFactory.setConnectTimeout(getConnectTimeout());
|
||||
requestFactory.setReadTimeout(getReadTimeout());
|
||||
rest.setRequestFactory(requestFactory);
|
||||
}
|
||||
|
||||
@@ -80,6 +78,27 @@ final class JwtDecoderProviderConfigurationUtils {
|
||||
private JwtDecoderProviderConfigurationUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default HTTP connect timeout, in milliseconds, for fetching
|
||||
* provider/JWK Set metadata. Honors the JDK's
|
||||
* {@code sun.net.client.defaultConnectTimeout} system property when set, otherwise
|
||||
* defaults to 30 seconds.
|
||||
* @return the default HTTP connect timeout, in milliseconds
|
||||
*/
|
||||
static int getConnectTimeout() {
|
||||
return Integer.parseInt(System.getProperty("sun.net.client.defaultConnectTimeout", "30000"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default HTTP read timeout, in milliseconds, for fetching provider/JWK
|
||||
* Set metadata. Honors the JDK's {@code sun.net.client.defaultReadTimeout} system
|
||||
* property when set, otherwise defaults to 30 seconds.
|
||||
* @return the default HTTP read timeout, in milliseconds
|
||||
*/
|
||||
static int getReadTimeout() {
|
||||
return Integer.parseInt(System.getProperty("sun.net.client.defaultReadTimeout", "30000"));
|
||||
}
|
||||
|
||||
static Map<String, Object> getConfigurationForOidcIssuerLocation(String oidcIssuerLocation) {
|
||||
return getConfiguration(oidcIssuerLocation, rest, oidc(oidcIssuerLocation));
|
||||
}
|
||||
|
||||
+5
-5
@@ -296,7 +296,7 @@ public final class NimbusJwtDecoder implements JwtDecoder {
|
||||
|
||||
private final Set<SignatureAlgorithm> signatureAlgorithms = new HashSet<>();
|
||||
|
||||
private RestOperations restOperations = new RestTemplateWithNimbusDefaultTimeouts();
|
||||
private RestOperations restOperations = new RestTemplateWithDefaultTimeouts();
|
||||
|
||||
private Cache cache = new NoOpCache("default");
|
||||
|
||||
@@ -568,12 +568,12 @@ public final class NimbusJwtDecoder implements JwtDecoder {
|
||||
* A RestTemplate with timeouts configured to avoid blocking indefinitely when
|
||||
* fetching JWK Sets while holding the reentrantLock.
|
||||
*/
|
||||
private static final class RestTemplateWithNimbusDefaultTimeouts extends RestTemplate {
|
||||
private static final class RestTemplateWithDefaultTimeouts extends RestTemplate {
|
||||
|
||||
private RestTemplateWithNimbusDefaultTimeouts() {
|
||||
private RestTemplateWithDefaultTimeouts() {
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(JWKSourceBuilder.DEFAULT_HTTP_CONNECT_TIMEOUT);
|
||||
requestFactory.setReadTimeout(JWKSourceBuilder.DEFAULT_HTTP_READ_TIMEOUT);
|
||||
requestFactory.setConnectTimeout(JwtDecoderProviderConfigurationUtils.getConnectTimeout());
|
||||
requestFactory.setReadTimeout(JwtDecoderProviderConfigurationUtils.getReadTimeout());
|
||||
setRequestFactory(requestFactory);
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -30,6 +30,7 @@ import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jose.util.Base64URL;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.oauth2.jose.TestKeys;
|
||||
@@ -46,6 +47,40 @@ import static org.mockito.BDDMockito.mock;
|
||||
|
||||
public class JwtDecoderProviderConfigurationUtilsTests {
|
||||
|
||||
@AfterEach
|
||||
public void cleanup() {
|
||||
System.clearProperty("sun.net.client.defaultConnectTimeout");
|
||||
System.clearProperty("sun.net.client.defaultReadTimeout");
|
||||
}
|
||||
|
||||
// gh-19474
|
||||
@Test
|
||||
public void getConnectTimeoutWhenPropertyNotSetThenDefaultsToThirtySeconds() {
|
||||
System.clearProperty("sun.net.client.defaultConnectTimeout");
|
||||
assertThat(JwtDecoderProviderConfigurationUtils.getConnectTimeout()).isEqualTo(30000);
|
||||
}
|
||||
|
||||
// gh-19474
|
||||
@Test
|
||||
public void getConnectTimeoutWhenPropertySetThenUses() {
|
||||
System.setProperty("sun.net.client.defaultConnectTimeout", "5000");
|
||||
assertThat(JwtDecoderProviderConfigurationUtils.getConnectTimeout()).isEqualTo(5000);
|
||||
}
|
||||
|
||||
// gh-19474
|
||||
@Test
|
||||
public void getReadTimeoutWhenPropertyNotSetThenDefaultsToThirtySeconds() {
|
||||
System.clearProperty("sun.net.client.defaultReadTimeout");
|
||||
assertThat(JwtDecoderProviderConfigurationUtils.getReadTimeout()).isEqualTo(30000);
|
||||
}
|
||||
|
||||
// gh-19474
|
||||
@Test
|
||||
public void getReadTimeoutWhenPropertySetThenUses() {
|
||||
System.setProperty("sun.net.client.defaultReadTimeout", "5000");
|
||||
assertThat(JwtDecoderProviderConfigurationUtils.getReadTimeout()).isEqualTo(5000);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getSignatureAlgorithmsWhenJwkSetSpecifiesAlgorithmThenUses() throws Exception {
|
||||
JWKSource<SecurityContext> jwkSource = mock(JWKSource.class);
|
||||
|
||||
+21
@@ -67,14 +67,17 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jose.TestKeys;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestOperations;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
@@ -391,6 +394,24 @@ public class NimbusJwtDecoderTests {
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
// gh-19474
|
||||
@Test
|
||||
public void withJwkSetUriWhenDefaultRestOperationsThenUsesConfiguredTimeouts() {
|
||||
try {
|
||||
System.setProperty("sun.net.client.defaultConnectTimeout", "12345");
|
||||
System.setProperty("sun.net.client.defaultReadTimeout", "23456");
|
||||
NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder.withJwkSetUri(JWK_SET_URI);
|
||||
RestOperations restOperations = (RestOperations) ReflectionTestUtils.getField(builder, "restOperations");
|
||||
ClientHttpRequestFactory requestFactory = ((RestTemplate) restOperations).getRequestFactory();
|
||||
assertThat(ReflectionTestUtils.getField(requestFactory, "connectTimeout")).isEqualTo(12345);
|
||||
assertThat(ReflectionTestUtils.getField(requestFactory, "readTimeout")).isEqualTo(23456);
|
||||
}
|
||||
finally {
|
||||
System.clearProperty("sun.net.client.defaultConnectTimeout");
|
||||
System.clearProperty("sun.net.client.defaultReadTimeout");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cacheWhenNullThenThrowsException() {
|
||||
NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder.withJwkSetUri(JWK_SET_URI);
|
||||
|
||||
+2
@@ -361,6 +361,8 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
|
||||
* @since 6.5
|
||||
*/
|
||||
public SpringOpaqueTokenIntrospector build() {
|
||||
Assert.notNull(this.clientId, "clientId cannot be null");
|
||||
Assert.notNull(this.clientSecret, "clientSecret cannot be null");
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(this.clientId, this.clientSecret));
|
||||
return new SpringOpaqueTokenIntrospector(this.introspectionUri, restTemplate);
|
||||
|
||||
+2
@@ -314,6 +314,8 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke
|
||||
* @since 6.5
|
||||
*/
|
||||
public SpringReactiveOpaqueTokenIntrospector build() {
|
||||
Assert.notNull(this.clientId, "clientId cannot be null");
|
||||
Assert.notNull(this.clientSecret, "clientSecret cannot be null");
|
||||
WebClient webClient = WebClient.builder()
|
||||
.defaultHeaders((h) -> h.setBasicAuth(this.clientId, this.clientSecret))
|
||||
.build();
|
||||
|
||||
+7
@@ -383,6 +383,13 @@ public class SpringOpaqueTokenIntrospectorTests {
|
||||
}
|
||||
}
|
||||
|
||||
// gh-19201
|
||||
@Test
|
||||
public void builderWhenMissingClientCredentialsThenThrowsException() {
|
||||
assertThatExceptionOfType(IllegalArgumentException.class)
|
||||
.isThrownBy(() -> SpringOpaqueTokenIntrospector.withIntrospectionUri(INTROSPECTION_URL).build());
|
||||
}
|
||||
|
||||
private static ResponseEntity<Map<String, Object>> response(String content) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
+7
@@ -308,6 +308,13 @@ public class SpringReactiveOpaqueTokenIntrospectorTests {
|
||||
}
|
||||
}
|
||||
|
||||
// gh-19201
|
||||
@Test
|
||||
public void builderWhenMissingClientCredentialsThenThrowsException() {
|
||||
assertThatExceptionOfType(IllegalArgumentException.class)
|
||||
.isThrownBy(() -> SpringReactiveOpaqueTokenIntrospector.withIntrospectionUri(INTROSPECTION_URL).build());
|
||||
}
|
||||
|
||||
private WebClient mockResponse(String response) {
|
||||
return mockResponse(toMap(response));
|
||||
}
|
||||
|
||||
@@ -137,6 +137,14 @@ tasks.register("opensaml5Test", Test) {
|
||||
classpath = sourceSets.opensaml5Test.output + sourceSets.opensaml5Test.runtimeClasspath
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
dependsOn opensaml5Test
|
||||
tasks.register("bouncyCastleTest", Test) {
|
||||
useJUnitPlatform()
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
include "**/JdbcAssertingPartyMetadataRepositoryBouncyCastleTests.class"
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
exclude "**/JdbcAssertingPartyMetadataRepositoryBouncyCastleTests.class"
|
||||
dependsOn opensaml5Test, bouncyCastleTest
|
||||
}
|
||||
|
||||
+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
@@ -319,62 +319,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 new Saml2AuthenticationException(first);
|
||||
}
|
||||
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.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 new Saml2AuthenticationException(first);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -57,84 +58,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) {
|
||||
@@ -142,13 +133,14 @@ 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 = (authentication.getCredentials() instanceof Saml2ResponseAssertionAccessor assertion)
|
||||
? assertion.getNameId() : authentication.getName();
|
||||
if (!nameId.getValue().equals(name)) {
|
||||
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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+148
-8
@@ -16,6 +16,11 @@
|
||||
|
||||
package org.springframework.security.saml2.provider.service.registration;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.ObjectInputFilter;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.security.Security;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
@@ -23,11 +28,16 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.core.serializer.DefaultDeserializer;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.core.serializer.DefaultSerializer;
|
||||
import org.springframework.core.serializer.Deserializer;
|
||||
import org.springframework.core.serializer.Serializer;
|
||||
@@ -37,6 +47,7 @@ import org.springframework.jdbc.core.PreparedStatementSetter;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.core.SqlParameterValue;
|
||||
import org.springframework.security.saml2.core.Saml2X509Credential;
|
||||
import org.springframework.security.saml2.provider.service.registration.JdbcAssertingPartyMetadataRepository.AssertingPartyMetadataRowMapper.Saml2X509CredentialCollectionDeserializer;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration.AssertingPartyDetails;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.function.ThrowingFunction;
|
||||
@@ -51,7 +62,7 @@ public final class JdbcAssertingPartyMetadataRepository implements AssertingPart
|
||||
|
||||
private final JdbcOperations jdbcOperations;
|
||||
|
||||
private final RowMapper<AssertingPartyMetadata> assertingPartyMetadataRowMapper = new AssertingPartyMetadataRowMapper();
|
||||
private RowMapper<AssertingPartyMetadata> assertingPartyMetadataRowMapper = new AssertingPartyMetadataRowMapper();
|
||||
|
||||
private final AssertingPartyMetadataParametersMapper assertingPartyMetadataParametersMapper = new AssertingPartyMetadataParametersMapper();
|
||||
|
||||
@@ -145,13 +156,34 @@ public final class JdbcAssertingPartyMetadataRepository implements AssertingPart
|
||||
return this.jdbcOperations.update(UPDATE_CREDENTIAL_RECORD_SQL, parameters.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the {@link RowMapper} to use for reading {@link AssertingPartyMetadata} records
|
||||
* from the database. By default, {@link AssertingPartyMetadataRowMapper} is used.
|
||||
*
|
||||
* <p>
|
||||
* Note that the default row mapper protects against insecure deserialization of
|
||||
* credentials by way of {@link Saml2X509CredentialCollectionDeserializer}, which uses
|
||||
* an allowlist of classes that can be deserialized. If a custom row mapper is used,
|
||||
* it is the responsibility of the developer to ensure that any deserialization of
|
||||
* credentials is done securely.
|
||||
* @param rowMapper - the rowMapper to use
|
||||
* @since 7.0.5
|
||||
* @see AssertingPartyMetadataRowMapper
|
||||
*/
|
||||
public void setRowMapper(RowMapper<AssertingPartyMetadata> rowMapper) {
|
||||
Assert.notNull(rowMapper, "rowMapper cannot be null");
|
||||
this.assertingPartyMetadataRowMapper = rowMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default {@link RowMapper} that maps the current row in
|
||||
* {@code java.sql.ResultSet} to {@link AssertingPartyMetadata}.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
private static final class AssertingPartyMetadataRowMapper implements RowMapper<AssertingPartyMetadata> {
|
||||
public static final class AssertingPartyMetadataRowMapper implements RowMapper<AssertingPartyMetadata> {
|
||||
|
||||
private final Deserializer<Object> deserializer = new DefaultDeserializer();
|
||||
private Deserializer<Collection<Saml2X509Credential>> deserializer = new Saml2X509CredentialCollectionDeserializer();
|
||||
|
||||
@Override
|
||||
public AssertingPartyMetadata mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||
@@ -162,8 +194,7 @@ public final class JdbcAssertingPartyMetadataRepository implements AssertingPart
|
||||
List<String> algorithms = List.of(rs.getString(COLUMN_NAMES[4]).split(","));
|
||||
byte[] verificationCredentialsBytes = rs.getBytes(COLUMN_NAMES[5]);
|
||||
byte[] encryptionCredentialsBytes = rs.getBytes(COLUMN_NAMES[6]);
|
||||
ThrowingFunction<byte[], Collection<Saml2X509Credential>> credentials = (
|
||||
bytes) -> (Collection<Saml2X509Credential>) this.deserializer.deserializeFromByteArray(bytes);
|
||||
ThrowingFunction<byte[], Collection<Saml2X509Credential>> credentials = this.deserializer::deserializeFromByteArray;
|
||||
AssertingPartyMetadata.Builder<?> builder = new AssertingPartyDetails.Builder();
|
||||
Collection<Saml2X509Credential> verificationCredentials = credentials.apply(verificationCredentialsBytes);
|
||||
Collection<Saml2X509Credential> encryptionCredentials = (encryptionCredentialsBytes != null)
|
||||
@@ -185,12 +216,121 @@ public final class JdbcAssertingPartyMetadataRepository implements AssertingPart
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the {@link Deserializer} to use for reading the credential collection from
|
||||
* the database. By default, {@link Saml2X509CredentialCollectionDeserializer} is
|
||||
* used, which uses Java Object serialization.
|
||||
*
|
||||
* <p>
|
||||
* If a custom deserializer is used, it is the responsibility of the developer to
|
||||
* ensure that any deserialization of credentials is done securely.
|
||||
* @param deserializer the deserializer to use
|
||||
*/
|
||||
public void setCredentialsDeserializer(Deserializer<Collection<Saml2X509Credential>> deserializer) {
|
||||
Assert.notNull(deserializer, "deserializer cannot be null");
|
||||
this.deserializer = deserializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default deserializer for verification and encryption credentials.
|
||||
*
|
||||
* <p>
|
||||
* This is equipped with an allowlist of classes that can be deserialized. If you
|
||||
* implement your own, you are responsible for to protecte against insecure
|
||||
* deserialization.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
public static final class Saml2X509CredentialCollectionDeserializer
|
||||
implements Deserializer<Collection<Saml2X509Credential>> {
|
||||
|
||||
private static final AllowlistObjectInputFilter ALLOWLIST;
|
||||
|
||||
static {
|
||||
Set<String> classes = new LinkedHashSet<>();
|
||||
classes.add(Saml2X509Credential.class.getName());
|
||||
classes.add(Saml2X509Credential.Saml2X509CredentialType.class.getName());
|
||||
classes.add(Enum.class.getName());
|
||||
classes.add("java.security.cert.Certificate$CertificateRep");
|
||||
classes.add("sun.security.x509.X509CertImpl");
|
||||
classes.add(LinkedHashSet.class.getName());
|
||||
classes.add(HashSet.class.getName());
|
||||
classes.add("java.util.Map$Entry");
|
||||
if (Security.getProvider("BC") != null) {
|
||||
classes.add("org.bouncycastle.jcajce.provider.asymmetric.x509.X509CertificateObject");
|
||||
}
|
||||
ALLOWLIST = new AllowlistObjectInputFilter(classes);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Collection<Saml2X509Credential> deserialize(InputStream in) throws IOException {
|
||||
ObjectInputStream oin = new ObjectInputStream(in);
|
||||
oin.setObjectInputFilter(ALLOWLIST);
|
||||
try {
|
||||
Collection<Saml2X509Credential> credentials = (Collection<Saml2X509Credential>) oin.readObject();
|
||||
for (Object credential : credentials) {
|
||||
Assert.isInstanceOf(Saml2X509Credential.class, credential,
|
||||
"Deserialized object is not of type Saml2X509Credential");
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
catch (ClassNotFoundException ex) {
|
||||
throw new IOException("Failed to deserialize asserting party credential collection", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AllowlistObjectInputFilter implements ObjectInputFilter {
|
||||
|
||||
private static final Log logger = LogFactory.getLog(JdbcAssertingPartyMetadataRepository.class);
|
||||
|
||||
private static final int MAX_DEPTH = 20;
|
||||
|
||||
private static final int MAX_REFS = 1000;
|
||||
|
||||
private static final int MAX_ARRAY = 16384;
|
||||
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
|
||||
private final ObjectInputFilter delegate;
|
||||
|
||||
private AllowlistObjectInputFilter(Set<String> allowlist) {
|
||||
ObjectInputFilter pattern = ObjectInputFilter.Config
|
||||
.createFilter(String.join(";", allowlist) + ";!*" + ";maxdepth=" + MAX_DEPTH + ";maxrefs="
|
||||
+ MAX_REFS + ";maxarray=" + MAX_ARRAY + ";maxbytes=" + MAX_BYTES);
|
||||
ObjectInputFilter global = ObjectInputFilter.Config.getSerialFilter();
|
||||
this.delegate = (global != null) ? ObjectInputFilter.merge(pattern, global) : pattern;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Status checkInput(FilterInfo info) {
|
||||
Status status = this.delegate.checkInput(info);
|
||||
if (status != Status.REJECTED) {
|
||||
return status;
|
||||
}
|
||||
Class<?> c = info.serialClass();
|
||||
if (c != null) {
|
||||
logger.trace("Failed to deserialize due to [" + c.getName() + "] not being in the allowlist");
|
||||
}
|
||||
else {
|
||||
logger.trace("Failed to deserialize due to exceeding one the following limits: " + "depth=["
|
||||
+ info.depth() + " < " + MAX_DEPTH + "]" + ", refs=[" + info.references() + " < "
|
||||
+ MAX_REFS + "]" + ", bytes=[" + info.streamBytes() + " < " + MAX_BYTES + "]"
|
||||
+ ", arrayLength=[" + info.arrayLength() + " < " + MAX_ARRAY + "]");
|
||||
}
|
||||
return Status.REJECTED;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static class AssertingPartyMetadataParametersMapper
|
||||
private class AssertingPartyMetadataParametersMapper
|
||||
implements Function<AssertingPartyMetadata, List<SqlParameterValue>> {
|
||||
|
||||
private final Serializer<Object> serializer = new DefaultSerializer();
|
||||
private Serializer<Object> serializer = new DefaultSerializer();
|
||||
|
||||
@Override
|
||||
public List<SqlParameterValue> apply(AssertingPartyMetadata record) {
|
||||
|
||||
+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;
|
||||
@@ -47,6 +47,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;
|
||||
@@ -55,8 +56,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
|
||||
@@ -83,6 +84,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) {
|
||||
@@ -203,7 +206,7 @@ public final class Saml2LogoutRequestFilter extends OncePerRequestFilter {
|
||||
doRedirect(request, response, logoutResponse);
|
||||
}
|
||||
else {
|
||||
doPost(response, logoutResponse);
|
||||
doPost(request, response, logoutResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,55 +218,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++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+48
-1
@@ -33,6 +33,7 @@ import java.util.function.Consumer;
|
||||
import javax.xml.namespace.QName;
|
||||
|
||||
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;
|
||||
@@ -185,6 +186,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();
|
||||
@@ -513,7 +560,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");
|
||||
}
|
||||
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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.saml2.provider.service.registration;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InvalidClassException;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.security.Security;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
|
||||
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
|
||||
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
/**
|
||||
* Tests for {@link JdbcAssertingPartyMetadataRepository} when Bouncy Castle is the
|
||||
* preferred JCA provider. Run in their own forked JVM (see the {@code bouncyCastleTest}
|
||||
* Gradle task) so that the production code's allowlist is computed with BC registered and
|
||||
* so this provider change does not leak into other tests.
|
||||
*/
|
||||
class JdbcAssertingPartyMetadataRepositoryBouncyCastleTests {
|
||||
|
||||
private static final String SCHEMA_SQL_RESOURCE = "org/springframework/security/saml2/saml2-asserting-party-metadata-schema.sql";
|
||||
|
||||
static {
|
||||
Security.insertProviderAt(new BouncyCastleProvider(), 1);
|
||||
}
|
||||
|
||||
private EmbeddedDatabase db;
|
||||
|
||||
private JdbcAssertingPartyMetadataRepository repository;
|
||||
|
||||
private JdbcOperations jdbcOperations;
|
||||
|
||||
private final AssertingPartyMetadata metadata = TestRelyingPartyRegistrations.full()
|
||||
.build()
|
||||
.getAssertingPartyMetadata();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.db = createDb();
|
||||
this.jdbcOperations = new JdbcTemplate(this.db);
|
||||
this.repository = new JdbcAssertingPartyMetadataRepository(this.jdbcOperations);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
this.db.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByEntityIdWhenEntityPresentThenReturns() {
|
||||
this.repository.save(this.metadata);
|
||||
|
||||
AssertingPartyMetadata found = this.repository.findByEntityId(this.metadata.getEntityId());
|
||||
|
||||
assertAssertingPartyEquals(found, this.metadata);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveWhenExistingThenUpdates() {
|
||||
this.repository.save(this.metadata);
|
||||
boolean existing = this.metadata.getWantAuthnRequestsSigned();
|
||||
this.repository.save(this.metadata.mutate().wantAuthnRequestsSigned(!existing).build());
|
||||
boolean updated = this.repository.findByEntityId(this.metadata.getEntityId()).getWantAuthnRequestsSigned();
|
||||
assertThat(existing).isNotEqualTo(updated);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByEntityIdWhenSerializedTypeNotInAllowlistThenFailsDeserialization() throws Exception {
|
||||
this.repository.save(this.metadata);
|
||||
byte[] notAllowed = serialize(new HashMap<>(Map.of("not", "allowed")));
|
||||
this.jdbcOperations.update(
|
||||
"UPDATE saml2_asserting_party_metadata SET verification_credentials = ? WHERE entity_id = ?",
|
||||
notAllowed, this.metadata.getEntityId());
|
||||
|
||||
assertThatExceptionOfType(RuntimeException.class)
|
||||
.isThrownBy(() -> this.repository.findByEntityId(this.metadata.getEntityId()))
|
||||
.withRootCauseInstanceOf(InvalidClassException.class);
|
||||
}
|
||||
|
||||
private static byte[] serialize(Object value) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ObjectOutputStream oos = new ObjectOutputStream(bytes)) {
|
||||
oos.writeObject(value);
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static EmbeddedDatabase createDb() {
|
||||
// @formatter:off
|
||||
return new EmbeddedDatabaseBuilder()
|
||||
.generateUniqueName(true)
|
||||
.setType(EmbeddedDatabaseType.HSQL)
|
||||
.setScriptEncoding("UTF-8")
|
||||
.addScript(SCHEMA_SQL_RESOURCE)
|
||||
.build();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private void assertAssertingPartyEquals(AssertingPartyMetadata found, AssertingPartyMetadata expected) {
|
||||
assertThat(found).isNotNull();
|
||||
assertThat(found.getEntityId()).isEqualTo(expected.getEntityId());
|
||||
assertThat(found.getSingleSignOnServiceLocation()).isEqualTo(expected.getSingleSignOnServiceLocation());
|
||||
assertThat(found.getSingleSignOnServiceBinding()).isEqualTo(expected.getSingleSignOnServiceBinding());
|
||||
assertThat(found.getWantAuthnRequestsSigned()).isEqualTo(expected.getWantAuthnRequestsSigned());
|
||||
assertThat(found.getSingleLogoutServiceLocation()).isEqualTo(expected.getSingleLogoutServiceLocation());
|
||||
assertThat(found.getSingleLogoutServiceResponseLocation())
|
||||
.isEqualTo(expected.getSingleLogoutServiceResponseLocation());
|
||||
assertThat(found.getSingleLogoutServiceBinding()).isEqualTo(expected.getSingleLogoutServiceBinding());
|
||||
assertThat(found.getSigningAlgorithms()).containsAll(expected.getSigningAlgorithms());
|
||||
assertThat(found.getVerificationX509Credentials()).containsAll(expected.getVerificationX509Credentials());
|
||||
assertThat(found.getEncryptionX509Credentials()).containsAll(expected.getEncryptionX509Credentials());
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -16,7 +16,13 @@
|
||||
|
||||
package org.springframework.security.saml2.provider.service.registration;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InvalidClassException;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -24,12 +30,19 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
|
||||
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
|
||||
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
|
||||
import org.springframework.security.saml2.provider.service.registration.JdbcAssertingPartyMetadataRepository.AssertingPartyMetadataRowMapper;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* Tests for {@link JdbcAssertingPartyMetadataRepository}
|
||||
@@ -115,6 +128,36 @@ class JdbcAssertingPartyMetadataRepositoryTests {
|
||||
assertThat(existing).isNotEqualTo(updated);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveWhenCustomRowMapperThenUses() throws Exception {
|
||||
RowMapper<AssertingPartyMetadata> rowMapper = spy(new AssertingPartyMetadataRowMapper());
|
||||
this.repository.setRowMapper(rowMapper);
|
||||
this.repository.save(this.metadata);
|
||||
this.repository.findByEntityId(this.metadata.getEntityId());
|
||||
verify(rowMapper).mapRow(any(), eq(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByEntityIdWhenSerializedTypeNotInAllowlistThenFailsDeserialization() throws Exception {
|
||||
this.repository.save(this.metadata);
|
||||
byte[] notAllowed = serialize(new HashMap<>(Map.of("not", "allowed")));
|
||||
this.jdbcOperations.update(
|
||||
"UPDATE saml2_asserting_party_metadata SET verification_credentials = ? WHERE entity_id = ?",
|
||||
notAllowed, this.metadata.getEntityId());
|
||||
|
||||
assertThatExceptionOfType(RuntimeException.class)
|
||||
.isThrownBy(() -> this.repository.findByEntityId(this.metadata.getEntityId()))
|
||||
.withRootCauseInstanceOf(InvalidClassException.class);
|
||||
}
|
||||
|
||||
private static byte[] serialize(Object value) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ObjectOutputStream oos = new ObjectOutputStream(bytes)) {
|
||||
oos.writeObject(value);
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static EmbeddedDatabase createDb() {
|
||||
return createDb(SCHEMA_SQL_RESOURCE);
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+9
-10
@@ -118,7 +118,7 @@ public class Saml2LogoutRequestFilterTests {
|
||||
verify(this.logoutRequestValidator).validate(any());
|
||||
verify(this.logoutHandler).logout(any(), any(), any());
|
||||
verify(this.logoutResponseResolver).resolve(any(), any());
|
||||
checkResponse(response.getContentAsString(), registration);
|
||||
checkResponse(response, registration);
|
||||
verify(this.securityContextHolderStrategy).getContext();
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ public class Saml2LogoutRequestFilterTests {
|
||||
|
||||
this.logoutRequestProcessingFilter.doFilter(request, response, new MockFilterChain());
|
||||
|
||||
checkResponse(response.getContentAsString(), registration);
|
||||
checkResponse(response, registration);
|
||||
verify(this.logoutRequestValidator).validate(any());
|
||||
verify(this.logoutResponseResolver).resolve(any(), any(),
|
||||
argThat((ex) -> ex.getSaml2Error().getErrorCode().equals(Saml2ErrorCodes.INVALID_REQUEST)));
|
||||
@@ -194,7 +194,7 @@ public class Saml2LogoutRequestFilterTests {
|
||||
|
||||
this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
|
||||
|
||||
checkResponse(response.getContentAsString(), registration);
|
||||
checkResponse(response, registration);
|
||||
verify(this.logoutResponseResolver).resolve(any(), any(),
|
||||
argThat((ex) -> ex.getSaml2Error().getErrorCode().equals(Saml2ErrorCodes.INVALID_DESTINATION)));
|
||||
verifyNoInteractions(this.logoutHandler);
|
||||
@@ -225,7 +225,7 @@ public class Saml2LogoutRequestFilterTests {
|
||||
|
||||
this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain());
|
||||
|
||||
checkResponse(response.getContentAsString(), registration);
|
||||
checkResponse(response, registration);
|
||||
verify(this.logoutResponseResolver).resolve(any(), any(),
|
||||
argThat((ex) -> ex.getSaml2Error().getErrorCode().equals(Saml2ErrorCodes.INVALID_REQUEST)));
|
||||
verifyNoInteractions(this.logoutHandler);
|
||||
@@ -259,15 +259,14 @@ public class Saml2LogoutRequestFilterTests {
|
||||
verifyNoInteractions(this.logoutHandler);
|
||||
}
|
||||
|
||||
private void checkResponse(String responseContent, RelyingPartyRegistration registration) {
|
||||
private void checkResponse(MockHttpServletResponse response, RelyingPartyRegistration registration)
|
||||
throws Exception {
|
||||
String responseContent = response.getContentAsString();
|
||||
assertThat(responseContent).contains(Saml2ParameterNames.SAML_RESPONSE);
|
||||
assertThat(responseContent)
|
||||
.contains(registration.getAssertingPartyMetadata().getSingleLogoutServiceResponseLocation());
|
||||
assertThat(responseContent).contains(
|
||||
"<meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'sha256-oZhLbc2kO8b8oaYLrUc7uye1MgVKMyLtPqWR4WtKF+c='\">");
|
||||
assertThat(responseContent)
|
||||
.contains("<script>window.onload = function() { document.forms[0].submit(); }</script>");
|
||||
|
||||
assertThat(response.getHeader("Content-Security-Policy")).matches("script-src 'nonce-.+'");
|
||||
assertThat(responseContent).contains("document.getElementById(\"redirect-form\").submit();");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-3
@@ -99,9 +99,8 @@ public class Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests {
|
||||
String content = response.getContentAsString();
|
||||
assertThat(content).contains(Saml2ParameterNames.SAML_REQUEST);
|
||||
assertThat(content).contains(registration.getAssertingPartyMetadata().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-.+'");
|
||||
assertThat(content).contains("document.getElementById(\"redirect-form\").submit();");
|
||||
}
|
||||
|
||||
private Saml2Authentication authentication(RelyingPartyRegistration registration) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map.Entry;
|
||||
@@ -30,6 +31,7 @@ import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.StringKeyGenerator;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
|
||||
/**
|
||||
* Redirect using an auto-submitting HTML form using the POST method. All query params
|
||||
@@ -83,8 +85,12 @@ public final class FormPostRedirectStrategy implements RedirectStrategy {
|
||||
|
||||
final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder();
|
||||
for (final Entry<String, List<String>> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) {
|
||||
final String name = entry.getKey();
|
||||
for (final String value : entry.getValue()) {
|
||||
final String name = UriUtils.decode(entry.getKey(), StandardCharsets.UTF_8);
|
||||
for (final String raw : entry.getValue()) {
|
||||
if (raw == null) {
|
||||
continue;
|
||||
}
|
||||
final String value = UriUtils.decode(raw, StandardCharsets.UTF_8);
|
||||
// @formatter:off
|
||||
final String hiddenInput = HIDDEN_INPUT_TEMPLATE
|
||||
.replace("{{name}}", HtmlUtils.htmlEscape(name))
|
||||
|
||||
+6
-1
@@ -17,6 +17,8 @@
|
||||
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;
|
||||
@@ -72,7 +74,10 @@ public final class SubjectX500PrincipalExtractor implements X509PrincipalExtract
|
||||
|
||||
private List<Rdn> getDns(String subjectDn) {
|
||||
try {
|
||||
return new LdapName(subjectDn).getRdns();
|
||||
// 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);
|
||||
|
||||
+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.Objects;
|
||||
@@ -63,7 +64,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());
|
||||
@@ -84,10 +85,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)
|
||||
@@ -97,15 +102,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
|
||||
@@ -130,13 +128,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 @Nullable 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;
|
||||
@@ -97,8 +98,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
|
||||
@@ -143,11 +149,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() {
|
||||
|
||||
@@ -101,6 +101,19 @@ public class FormPostRedirectStrategyTests {
|
||||
assertThat(this.response).satisfies(hasScriptSrcNonce());
|
||||
}
|
||||
|
||||
// gh-19136
|
||||
|
||||
@Test
|
||||
public void absoluteUrlWithPercentEncodedQueryParamsRedirect() throws IOException {
|
||||
this.redirectStrategy.sendRedirect(this.request, this.response, "https://example.com/cb?payload=a%2Bb%2Fc%3D");
|
||||
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
|
||||
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
|
||||
assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/cb\"");
|
||||
assertThat(this.response.getContentAsString())
|
||||
.contains("<input name=\"payload\" type=\"hidden\" value=\"a+b/c=\" />");
|
||||
assertThat(this.response).satisfies(hasScriptSrcNonce());
|
||||
}
|
||||
|
||||
private ThrowingConsumer<MockHttpServletResponse> hasScriptSrcNonce() {
|
||||
return (response) -> {
|
||||
final String policyDirective = response.getHeader("Content-Security-Policy");
|
||||
|
||||
+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));
|
||||
|
||||
+8
@@ -60,6 +60,14 @@ public class SubjectX500PrincipalExtractorTests {
|
||||
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);
|
||||
|
||||
+34
@@ -185,4 +185,38 @@ public final class X509TestUtils {
|
||||
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