From 46452c0cae3938ba1ed408a4cb1ac133ac9f0e80 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 9 Mar 2023 09:23:45 -0700 Subject: [PATCH] Add saml2Metadata Closes gh-11828 --- .../annotation/web/builders/HttpSecurity.java | 97 ++++++++ .../saml2/Saml2MetadataConfigurer.java | 169 ++++++++++++++ .../config/annotation/web/HttpSecurityDsl.kt | 37 +++ .../config/annotation/web/Saml2MetadataDsl.kt | 52 +++++ .../saml2/Saml2MetadataConfigurerTests.java | 210 ++++++++++++++++++ .../annotation/web/Saml2MetadataDslTests.kt | 189 ++++++++++++++++ .../ROOT/pages/servlet/saml2/metadata.adoc | 90 +++----- 7 files changed, 780 insertions(+), 64 deletions(-) create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java create mode 100644 config/src/main/kotlin/org/springframework/security/config/annotation/web/Saml2MetadataDsl.kt create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java create mode 100644 config/src/test/kotlin/org/springframework/security/config/annotation/web/Saml2MetadataDslTests.kt diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index da2bc756f5..338e1ef6bf 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -73,6 +73,7 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.cli import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer; +import org.springframework.security.config.annotation.web.configurers.saml2.Saml2MetadataConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -2425,6 +2426,102 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder(getContext())); } + /** + * Configures a SAML 2.0 metadata endpoint that presents relying party configurations + * in an {@code } payload. + * + *

+ * By default, the endpoints are {@code /saml2/metadata} and + * {@code /saml2/metadata/{registrationId}} though note that also + * {@code /saml2/service-provider-metadata/{registrationId}} is recognized for + * backward compatibility purposes. + * + *

+ *

Example Configuration

+ * + * The following example shows the minimal configuration required, using a + * hypothetical asserting party. + * + *
+	 *	@EnableWebSecurity
+	 *	@Configuration
+	 *	public class Saml2LogoutSecurityConfig {
+	 *		@Bean
+	 *		public SecurityFilterChain web(HttpSecurity http) throws Exception {
+	 *			http
+	 *				.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
+	 *				.saml2Metadata(Customizer.withDefaults());
+	 *			return http.build();
+	 *		}
+	 *
+	 *		@Bean
+	 *		public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
+	 *			RelyingPartyRegistration registration = RelyingPartyRegistrations
+	 *					.withMetadataLocation("https://ap.example.org/metadata")
+	 *					.registrationId("simple")
+	 *					.build();
+	 *			return new InMemoryRelyingPartyRegistrationRepository(registration);
+	 *		}
+	 *	}
+	 * 
+ * @param saml2MetadataConfigurer the {@link Customizer} to provide more options for + * the {@link Saml2MetadataConfigurer} + * @return the {@link HttpSecurity} for further customizations + * @throws Exception + * @since 6.1 + */ + public HttpSecurity saml2Metadata(Customizer> saml2MetadataConfigurer) + throws Exception { + saml2MetadataConfigurer.customize(getOrApply(new Saml2MetadataConfigurer<>(getContext()))); + return HttpSecurity.this; + } + + /** + * Configures a SAML 2.0 metadata endpoint that presents relying party configurations + * in an {@code } payload. + * + *

+ * By default, the endpoints are {@code /saml2/metadata} and + * {@code /saml2/metadata/{registrationId}} though note that also + * {@code /saml2/service-provider-metadata/{registrationId}} is recognized for + * backward compatibility purposes. + * + *

+ *

Example Configuration

+ * + * The following example shows the minimal configuration required, using a + * hypothetical asserting party. + * + *
+	 *	@EnableWebSecurity
+	 *	@Configuration
+	 *	public class Saml2LogoutSecurityConfig {
+	 *		@Bean
+	 *		public SecurityFilterChain web(HttpSecurity http) throws Exception {
+	 *			http
+	 *				.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
+	 *				.saml2Metadata(Customizer.withDefaults());
+	 *			return http.build();
+	 *		}
+	 *
+	 *		@Bean
+	 *		public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
+	 *			RelyingPartyRegistration registration = RelyingPartyRegistrations
+	 *					.withMetadataLocation("https://ap.example.org/metadata")
+	 *					.registrationId("simple")
+	 *					.build();
+	 *			return new InMemoryRelyingPartyRegistrationRepository(registration);
+	 *		}
+	 *	}
+	 * 
+ * @return the {@link Saml2MetadataConfigurer} for further customizations + * @throws Exception + * @since 6.1 + */ + public Saml2MetadataConfigurer saml2Metadata() throws Exception { + return getOrApply(new Saml2MetadataConfigurer<>(getContext())); + } + /** * Configures authentication support using an OAuth 2.0 and/or OpenID Connect 1.0 * Provider.
diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java new file mode 100644 index 0000000000..d64e93a243 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java @@ -0,0 +1,169 @@ +/* + * Copyright 2002-2023 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.web.configurers.saml2; + +import java.util.function.Function; + +import org.springframework.context.ApplicationContext; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.saml2.provider.service.metadata.OpenSamlMetadataResolver; +import org.springframework.security.saml2.provider.service.metadata.RequestMatcherMetadataResponseResolver; +import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponseResolver; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.Assert; + +/** + * An {@link AbstractHttpConfigurer} for SAML 2.0 Metadata. + * + *

+ * SAML 2.0 Metadata provides an application with the capability to publish configuration + * information as a {@code } or {@code }. + * + *

+ * Defaults are provided for all configuration options with the only required + * configuration being a {@link Saml2LoginConfigurer#relyingPartyRegistrationRepository}. + * Alternatively, a {@link RelyingPartyRegistrationRepository} {@code @Bean} may be + * registered instead. + * + *

Security Filters

+ * + * The following {@code Filter} is populated: + * + *
    + *
  • {@link Saml2MetadataFilter}
  • + *
+ * + *

Shared Objects Created

+ * + * none + * + *

Shared Objects Used

+ * + * The following shared objects are used: + * + *
    + *
  • {@link RelyingPartyRegistrationRepository} (required)
  • + *
+ * + * @since 6.1 + * @see HttpSecurity#saml2Metadata() + * @see Saml2MetadataFilter + * @see RelyingPartyRegistrationRepository + */ +public class Saml2MetadataConfigurer> + extends AbstractHttpConfigurer, H> { + + private final ApplicationContext context; + + private Function metadataResponseResolver; + + public Saml2MetadataConfigurer(ApplicationContext context) { + this.context = context; + } + + /** + * Use this endpoint to request relying party metadata. + * + *

+ * If you specify a {@code registrationId} placeholder in the URL, then the filter + * will lookup a {@link RelyingPartyRegistration} using that. + * + *

+ * If there is no {@code registrationId} and your + * {@link RelyingPartyRegistrationRepository} is {code Iterable}, the metadata + * endpoint will try and show all relying parties' metadata in a single + * {@code + * If you need a more sophisticated lookup strategy than these, use + * {@link #metadataResponseResolver} instead. + * @param metadataUrl the url to use + * @return the {@link Saml2MetadataConfigurer} for more customizations + */ + public Saml2MetadataConfigurer metadataUrl(String metadataUrl) { + Assert.hasText(metadataUrl, "metadataUrl cannot be empty"); + this.metadataResponseResolver = (registrations) -> { + RequestMatcherMetadataResponseResolver metadata = new RequestMatcherMetadataResponseResolver(registrations, + new OpenSamlMetadataResolver()); + metadata.setRequestMatcher(new AntPathRequestMatcher(metadataUrl)); + return metadata; + }; + return this; + } + + /** + * Use this {@link Saml2MetadataResponseResolver} to parse the request and respond + * with SAML 2.0 metadata. + * @param metadataResponseResolver to use + * @return the {@link Saml2MetadataConfigurer} for more customizations + */ + public Saml2MetadataConfigurer metadataResponseResolver(Saml2MetadataResponseResolver metadataResponseResolver) { + Assert.notNull(metadataResponseResolver, "metadataResponseResolver cannot be null"); + this.metadataResponseResolver = (registrations) -> metadataResponseResolver; + return this; + } + + public H and() { + return getBuilder(); + } + + @Override + public void configure(H http) throws Exception { + Saml2MetadataResponseResolver metadataResponseResolver = createMetadataResponseResolver(http); + http.addFilterBefore(new Saml2MetadataFilter(metadataResponseResolver), BasicAuthenticationFilter.class); + } + + private Saml2MetadataResponseResolver createMetadataResponseResolver(H http) { + if (this.metadataResponseResolver != null) { + RelyingPartyRegistrationRepository registrations = getRelyingPartyRegistrationRepository(http); + return this.metadataResponseResolver.apply(registrations); + } + Saml2MetadataResponseResolver metadataResponseResolver = getBeanOrNull(Saml2MetadataResponseResolver.class); + if (metadataResponseResolver != null) { + return metadataResponseResolver; + } + RelyingPartyRegistrationRepository registrations = getRelyingPartyRegistrationRepository(http); + return new RequestMatcherMetadataResponseResolver(registrations, new OpenSamlMetadataResolver()); + } + + private RelyingPartyRegistrationRepository getRelyingPartyRegistrationRepository(H http) { + Saml2LoginConfigurer login = http.getConfigurer(Saml2LoginConfigurer.class); + if (login != null) { + return login.relyingPartyRegistrationRepository(http); + } + else { + return getBeanOrNull(RelyingPartyRegistrationRepository.class); + } + } + + private C getBeanOrNull(Class clazz) { + if (this.context == null) { + return null; + } + if (this.context.getBeanNamesForType(clazz).length == 0) { + return null; + } + return this.context.getBean(clazz); + } + +} diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt index 8199217c8a..2661343415 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt @@ -677,6 +677,43 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu this.http.saml2Login(saml2LoginCustomizer) } + /** + * Configures a SAML 2.0 relying party metadata endpoint. + * + * A [RelyingPartyRegistrationRepository] is required and must be registered with + * the [ApplicationContext] or configured via + * [Saml2Dsl.relyingPartyRegistrationRepository] + * + * Example: + * + * The following example shows the minimal configuration required, using a + * hypothetical asserting party. + * + * ``` + * @Configuration + * @EnableWebSecurity + * class SecurityConfig { + * + * @Bean + * fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + * http { + * saml2Login { } + * saml2Metadata { } + * } + * return http.build() + * } + * } + * ``` + * @param saml2MetadataConfiguration custom configuration to configure the + * SAML2 relying party metadata endpoint + * @see [Saml2MetadataDsl] + * @since 6.1 + */ + fun saml2Metadata(saml2MetadataConfiguration: Saml2MetadataDsl.() -> Unit) { + val saml2MetadataCustomizer = Saml2MetadataDsl().apply(saml2MetadataConfiguration).get() + this.http.saml2Metadata(saml2MetadataCustomizer) + } + /** * Allows configuring how an anonymous user is represented. * diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/Saml2MetadataDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/Saml2MetadataDsl.kt new file mode 100644 index 0000000000..f0d8b429e5 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/Saml2MetadataDsl.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2022 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.web + +import org.springframework.security.authentication.AuthenticationManagerResolver +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.oauth2.resourceserver.JwtDsl +import org.springframework.security.config.annotation.web.oauth2.resourceserver.OpaqueTokenDsl +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.access.AccessDeniedHandler +import jakarta.servlet.http.HttpServletRequest +import org.springframework.security.config.annotation.web.configurers.saml2.Saml2MetadataConfigurer +import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponseResolver + +/** + * A Kotlin DSL to configure [HttpSecurity] SAML 2.0 relying party metadata support using + * idiomatic Kotlin code. + * + * @author Josh Cummings + * @since 6.1 + * @property metadataUrl the name of the relying party metadata endpoint; defaults to `/saml2/metadata` and `/saml2/metadata/{registrationId}` + * @property metadataResponseResolver the [Saml2MetadataResponseResolver] to use for resolving the + * metadata request into metadata + */ +@SecurityMarker +class Saml2MetadataDsl { + var metadataUrl: String? = null + var metadataResponseResolver: Saml2MetadataResponseResolver? = null + + internal fun get(): (Saml2MetadataConfigurer) -> Unit { + return { saml2Metadata -> + metadataResponseResolver?.also { saml2Metadata.metadataResponseResolver(metadataResponseResolver) } + metadataUrl?.also { saml2Metadata.metadataUrl(metadataUrl) } + } + } +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java new file mode 100644 index 0000000000..35838285a8 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java @@ -0,0 +1,210 @@ +/* + * Copyright 2002-2023 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.web.configurers.saml2; + +import com.google.common.net.HttpHeaders; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.saml2.provider.service.metadata.OpenSamlMetadataResolver; +import org.springframework.security.saml2.provider.service.metadata.RequestMatcherMetadataResponseResolver; +import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponse; +import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponseResolver; +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link Saml2MetadataConfigurer} + */ +@ExtendWith(SpringTestContextExtension.class) +public class Saml2MetadataConfigurerTests { + + static RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build(); + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired(required = false) + MockMvc mvc; + + @Test + void saml2MetadataRegistrationIdWhenDefaultsThenReturnsMetadata() throws Exception { + this.spring.register(DefaultConfig.class).autowire(); + String filename = "saml-" + registration.getRegistrationId() + "-metadata.xml"; + this.mvc.perform(get("/saml2/metadata/" + registration.getRegistrationId())).andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString(filename))) + .andExpect(content().string(containsString("md:EntityDescriptor"))); + } + + @Test + void saml2MetadataRegistrationIdWhenWrongIdThenUnauthorized() throws Exception { + this.spring.register(DefaultConfig.class).autowire(); + this.mvc.perform(get("/saml2/metadata/" + registration.getRegistrationId() + "wrong")) + .andExpect(status().isUnauthorized()); + } + + @Test + void saml2MetadataWhenDefaultsThenReturnsMetadata() throws Exception { + this.spring.register(DefaultConfig.class).autowire(); + this.mvc.perform(get("/saml2/metadata")).andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("-metadata.xml"))) + .andExpect(content().string(containsString("md:EntityDescriptor"))); + } + + @Test + void saml2MetadataWhenMetadataResponseResolverThenUses() throws Exception { + this.spring.register(DefaultConfig.class, MetadataResponseResolverConfig.class).autowire(); + Saml2MetadataResponseResolver metadataResponseResolver = this.spring.getContext() + .getBean(Saml2MetadataResponseResolver.class); + given(metadataResponseResolver.resolve(any(HttpServletRequest.class))) + .willReturn(new Saml2MetadataResponse("metadata", "filename")); + this.mvc.perform(get("/saml2/metadata")).andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("filename"))) + .andExpect(content().string(containsString("metadata"))); + verify(metadataResponseResolver).resolve(any(HttpServletRequest.class)); + } + + @Test + void saml2MetadataWhenMetadataResponseResolverDslThenUses() throws Exception { + this.spring.register(MetadataResponseResolverDslConfig.class).autowire(); + this.mvc.perform(get("/saml2/metadata")).andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("filename"))) + .andExpect(content().string(containsString("metadata"))); + } + + @Test + void saml2MetadataWhenMetadataUrlThenUses() throws Exception { + this.spring.register(MetadataUrlConfig.class).autowire(); + String filename = "saml-" + registration.getRegistrationId() + "-metadata.xml"; + this.mvc.perform(get("/saml/metadata")).andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString(filename))) + .andExpect(content().string(containsString("md:EntityDescriptor"))); + this.mvc.perform(get("/saml2/metadata")).andExpect(status().isForbidden()); + } + + @EnableWebSecurity + @Configuration + @Import(RelyingPartyRegistrationConfig.class) + static class DefaultConfig { + + @Bean + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .saml2Metadata(Customizer.withDefaults()); + return http.build(); + // @formatter:on + } + + } + + @EnableWebSecurity + @Configuration + @Import(RelyingPartyRegistrationConfig.class) + static class MetadataUrlConfig { + + @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 + } + + // should ignore + @Bean + Saml2MetadataResponseResolver metadataResponseResolver(RelyingPartyRegistrationRepository registrations) { + return new RequestMatcherMetadataResponseResolver(registrations, new OpenSamlMetadataResolver()); + } + + } + + @EnableWebSecurity + @Configuration + @Import(RelyingPartyRegistrationConfig.class) + static class MetadataResponseResolverDslConfig { + + Saml2MetadataResponseResolver metadataResponseResolver = mock(Saml2MetadataResponseResolver.class); + + { + given(this.metadataResponseResolver.resolve(any(HttpServletRequest.class))) + .willReturn(new Saml2MetadataResponse("metadata", "filename")); + } + + @Bean + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .saml2Metadata((saml2) -> saml2.metadataResponseResolver(this.metadataResponseResolver)); + return http.build(); + // @formatter:on + } + + } + + @Configuration + static class MetadataResponseResolverConfig { + + Saml2MetadataResponseResolver metadataResponseResolver = mock(Saml2MetadataResponseResolver.class); + + @Bean + Saml2MetadataResponseResolver metadataResponseResolver() { + return this.metadataResponseResolver; + } + + } + + @Configuration + static class RelyingPartyRegistrationConfig { + + RelyingPartyRegistrationRepository registrations = new InMemoryRelyingPartyRegistrationRepository(registration); + + @Bean + RelyingPartyRegistrationRepository registrations() { + return this.registrations; + } + + } + +} diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/Saml2MetadataDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/Saml2MetadataDslTests.kt new file mode 100644 index 0000000000..ffb5e3bee1 --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/Saml2MetadataDslTests.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2002-2022 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.web + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.BeanCreationException +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.io.ClassPathResource +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.ProviderManager +import org.springframework.security.authentication.TestingAuthenticationProvider +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.saml2.core.Saml2X509Credential +import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponse +import org.springframework.security.saml2.provider.service.metadata.Saml2MetadataResponseResolver +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations +import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter +import org.springframework.security.web.SecurityFilterChain +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.util.Base64 + +/** + * Tests for [Saml2Dsl] + * + * @author Eleftheria Stein + */ +@ExtendWith(SpringTestContextExtension::class) +class Saml2MetadataDslTests { + @JvmField + val spring = SpringTestContext(this) + + @Autowired + lateinit var mockMvc: MockMvc + + @Test + fun `saml2Metadat when no relying party registration repository then exception`() { + Assertions.assertThatThrownBy { this.spring.register(Saml2MetadataNoRelyingPartyRegistrationRepoConfig::class.java).autowire() } + .isInstanceOf(BeanCreationException::class.java) + .hasMessageContaining("relyingPartyRegistrationRepository cannot be null") + + } + + @Configuration + @EnableWebSecurity + open class Saml2MetadataNoRelyingPartyRegistrationRepoConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + saml2Metadata { } + } + return http.build() + } + } + + @Test + fun `metadata endpoint when saml2Metadata configured then metadata returned`() { + this.spring.register(Saml2MetadataConfig::class.java).autowire() + + this.mockMvc.get("/saml2/metadata") + .andExpect { + status { isOk() } + } + } + + @Configuration + @EnableWebSecurity + open class Saml2MetadataConfig { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + saml2Metadata { } + } + return http.build() + } + + @Bean + open fun registrations(): RelyingPartyRegistrationRepository { + return InMemoryRelyingPartyRegistrationRepository(TestRelyingPartyRegistrations.full().build()) + } + + private fun loadCert(location: String): T { + ClassPathResource(location).inputStream.use { inputStream -> + val certFactory = CertificateFactory.getInstance("X.509") + return certFactory.generateCertificate(inputStream) as T + } + } + } + + @Test + fun `metadata endpoint when url customized then used`() { + this.spring.register(Saml2LoginCustomEndpointConfig::class.java).autowire() + this.mockMvc.get("/saml/metadata") + .andExpect { + status { isOk() } + } + } + + @Configuration + @EnableWebSecurity + open class Saml2LoginCustomEndpointConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + saml2Metadata { + metadataUrl = "/saml/metadata" + } + } + return http.build() + } + + @Bean + open fun relyingPartyRegistrationRepository(): RelyingPartyRegistrationRepository? { + return InMemoryRelyingPartyRegistrationRepository(TestRelyingPartyRegistrations.full().build()) + } + } + + @Test + fun `metadata endpoint when resolver customized then used`() { + this.spring.register(Saml2LoginCustomMetadataResolverConfig::class.java).autowire() + val mocked = this.spring.context.getBean(Saml2MetadataResponseResolver::class.java) + every { + mocked.resolve(any()) + } returns Saml2MetadataResponse("metadata", "file") + this.mockMvc.get("/saml2/metadata") + .andExpect { + status { isOk() } + } + verify(exactly = 1) { mocked.resolve(any()) } + } + + @Configuration + @EnableWebSecurity + open class Saml2LoginCustomMetadataResolverConfig { + + private val metadataResponseResolver: Saml2MetadataResponseResolver = mockk() + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + saml2Metadata {} + } + return http.build() + } + + @Bean + open fun metadataResponseResolver(): Saml2MetadataResponseResolver? { + return this.metadataResponseResolver + } + + @Bean + open fun relyingPartyRegistrationRepository(): RelyingPartyRegistrationRepository? { + return InMemoryRelyingPartyRegistrationRepository(TestRelyingPartyRegistrations.full().build()) + } + } +} diff --git a/docs/modules/ROOT/pages/servlet/saml2/metadata.adoc b/docs/modules/ROOT/pages/servlet/saml2/metadata.adoc index 81aa06c72d..451d414e1b 100644 --- a/docs/modules/ROOT/pages/servlet/saml2/metadata.adoc +++ b/docs/modules/ROOT/pages/servlet/saml2/metadata.adoc @@ -32,38 +32,25 @@ val openSamlEntityDescriptor: EntityDescriptor = details.getEntityDescriptor(); [[publishing-relying-party-metadata]] == Producing `` Metadata -You can publish a metadata endpoint by adding the `Saml2MetadataFilter` to the filter chain, as you'll see below: +You can publish a metadata endpoint using the `saml2Metadata` DSL method, as you'll see below: ==== .Java [source,java,role="primary"] ---- -DefaultRelyingPartyRegistrationResolver relyingPartyRegistrationResolver = - new DefaultRelyingPartyRegistrationResolver(this.relyingPartyRegistrationRepository); -Saml2MetadataFilter filter = new Saml2MetadataFilter( - relyingPartyRegistrationResolver, - new OpenSamlMetadataResolver()); - http // ... .saml2Login(withDefaults()) - .addFilterBefore(filter, Saml2WebSsoAuthenticationFilter.class); + .saml2Metadata(withDefaults()); ---- .Kotlin [source,kotlin,role="secondary"] ---- -val relyingPartyRegistrationResolver: Converter = - DefaultRelyingPartyRegistrationResolver(this.relyingPartyRegistrationRepository) -val filter = Saml2MetadataFilter( - relyingPartyRegistrationResolver, - OpenSamlMetadataResolver() -) - http { //... saml2Login { } - addFilterBefore(filter) + saml2Metadata { } } ---- ==== @@ -71,77 +58,52 @@ http { You can use this metadata endpoint to register your relying party with your asserting party. This is often as simple as finding the correct form field to supply the metadata endpoint. -By default, the metadata endpoint is `+/saml2/service-provider-metadata/{registrationId}+`. -You can change this by calling the `setRequestMatcher` method on the filter: +By default, the metadata endpoint is `+/saml2/metadata+`, though it also responds to `+/saml2/metadata/{registrationId}+` and `+/saml2/service-provider-metadata/{registrationId}+`. + +You can change this by calling the `metadataUrl` method in the DSL: ==== .Java [source,java,role="primary"] ---- -filter.setRequestMatcher(new AntPathRequestMatcher("/saml2/metadata/{registrationId}", "GET")); +.saml2Metadata((saml2) -> saml2.metadataUrl("/saml/metadata")) ---- .Kotlin [source,kotlin,role="secondary"] ---- -filter.setRequestMatcher(AntPathRequestMatcher("/saml2/metadata/{registrationId}", "GET")) ----- -==== - -Or, if you have registered a custom relying party registration resolver in the constructor, then you can specify a path without a `registrationId` hint, like so: - -==== -.Java -[source,java,role="primary"] ----- -filter.setRequestMatcher(new AntPathRequestMatcher("/saml2/metadata", "GET")); ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -filter.setRequestMatcher(AntPathRequestMatcher("/saml2/metadata", "GET")) +saml2Metadata { + metadataUrl = "/saml/metadata" +} ---- ==== == Changing the Way a `RelyingPartyRegistration` Is Looked Up -To apply a custom `RelyingPartyRegistrationResolver` to the metadata endpoint, you can provide it directly in the filter constructor like so: +If you have a different strategy for identifying which `RelyingPartyRegistration` to use, you can configure your own `Saml2MetadataResponseResolver` like the one below: ==== .Java [source,java,role="primary"] ---- -RelyingPartyRegistrationResolver myRegistrationResolver = ...; -Saml2MetadataFilter metadata = new Saml2MetadataFilter(myRegistrationResolver, new OpenSamlMetadataResolver()); - -// ... - -http.addFilterBefore(metadata, BasicAuthenticationFilter.class); +@Bean +Saml2MetadataResponseResolver metadataResponseResolver(RelyingPartyRegistrationRepository registrations) { + RequestMatcherMetadataResponseResolver metadata = new RequestMatcherMetadataResponseResolver( + (id) -> registrations.findByRegistrationId("relying-party")); + metadata.setMetadataFilename("metadata.xml"); + return metadata; +} ---- .Kotlin +[source,kotlin,role="secondary"] ---- -val myRegistrationResolver: RelyingPartyRegistrationResolver = ...; -val metadata = new Saml2MetadataFilter(myRegistrationResolver, OpenSamlMetadataResolver()); - -// ... - -http.addFilterBefore(metadata, BasicAuthenticationFilter::class.java); ----- -==== - -In the event that you are applying a `RelyingPartyRegistrationResolver` to remove the `registrationId` from the URI, you must also change the URI in the filter like so: - -==== -.Java -[source,java,role="primary"] ----- -metadata.setRequestMatcher("/saml2/metadata") ----- - -.Kotlin ----- -metadata.setRequestMatcher("/saml2/metadata") +@Bean +fun metadataResponseResolver(val registrations: RelyingPartyRegistrationRepository): Saml2MetadataResponseResolver { + val metadata = new RequestMatcherMetadataResponseResolver( + id: String -> registrations.findByRegistrationId("relying-party")) + metadata.setMetadataFilename("metadata.xml") + return metadata +} ---- ====