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 60342d2af8..f13ce656b1 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -707,6 +707,69 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu this.http.saml2Login(saml2LoginCustomizer) } + /** + * Configures logout support for a SAML 2.0 Service Provider.
+ *
+ * + * Implements the Single Logout Profile, using POST and REDIRECT bindings, as + * documented in the + * SAML V2.0 + * Core, Profiles and Bindings specifications.
+ *
+ * + * As a prerequisite to using this feature, is that you have a SAML v2.0 Asserting + * Party to send a logout request to. The representation of the relying party and the + * asserting party is contained within [RelyingPartyRegistration].
+ *
+ * + * [RelyingPartyRegistration] (s) are composed within a + * [RelyingPartyRegistrationRepository], which is required and must be + * registered with the [ApplicationContext] or configured via + * [HttpSecurityDsl.saml2Login].
+ *
+ * + * The default configuration provides an auto-generated logout endpoint at + * `/logout` and redirects to `/login?logout` when + * logout completes.
+ *
+ * + *

+ *

Example Configuration

+ * + * The following example shows the minimal configuration required, using a + * hypothetical asserting party. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebSecurity + * class SecurityConfig { + * + * @Bean + * fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + * http { + * saml2Login { + * relyingPartyRegistration = getSaml2RelyingPartyRegistration() + * } + * saml2Logout { } + * } + * return http.build() + * } + * } + * ``` + * + *

+ * @param saml2LogoutConfiguration custom configuration to configure the + * SAML 2.0 service provider + * @since 6.3 + * @see [Saml2LogoutDsl] + */ + fun saml2Logout(saml2LogoutConfiguration: Saml2LogoutDsl.() -> Unit) { + val saml2LogoutCustomizer = Saml2LogoutDsl().apply(saml2LogoutConfiguration).get() + this.http.saml2Logout(saml2LogoutCustomizer) + } + /** * Configures a SAML 2.0 relying party metadata endpoint. * diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/Saml2LogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/Saml2LogoutDsl.kt new file mode 100644 index 0000000000..3a5b090b0e --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/Saml2LogoutDsl.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2021 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.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer +import org.springframework.security.config.annotation.web.saml2.LogoutRequestDsl +import org.springframework.security.config.annotation.web.saml2.LogoutResponseDsl +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository + +/** + * A Kotlin DSL to configure [HttpSecurity] SAML2 logout using idiomatic Kotlin code. + * + * @author Josh Cummings + * @since 6.3 + * @property relyingPartyRegistrationRepository the [RelyingPartyRegistrationRepository] of relying parties, + * each party representing a service provider, SP and this host, and identity provider, IDP pair that + * communicate with each other. + * @property logoutUrl the logout page to begin the SLO redirect flow + */ +@SecurityMarker +class Saml2LogoutDsl { + var relyingPartyRegistrationRepository: RelyingPartyRegistrationRepository? = null + var logoutUrl: String? = null + + private var logoutRequest: ((Saml2LogoutConfigurer.LogoutRequestConfigurer) -> Unit)? = null + private var logoutResponse: ((Saml2LogoutConfigurer.LogoutResponseConfigurer) -> Unit)? = null + + /** + * Configures SAML 2.0 Logout Request components + * @param logoutRequestConfig the {@link Customizer} to provide more + * options for the {@link LogoutRequestConfigurer} + */ + fun logoutRequest(logoutRequestConfig: LogoutRequestDsl.() -> Unit) { + this.logoutRequest = LogoutRequestDsl().apply(logoutRequestConfig).get() + } + + /** + * Configures SAML 2.0 Logout Response components + * @param logoutResponseConfig the {@link Customizer} to provide more + * options for the {@link LogoutResponseConfigurer} + */ + fun logoutResponse(logoutResponseConfig: LogoutResponseDsl.() -> Unit) { + this.logoutResponse = LogoutResponseDsl().apply(logoutResponseConfig).get() + } + + internal fun get(): (Saml2LogoutConfigurer) -> Unit { + return { saml2Logout -> + relyingPartyRegistrationRepository?.also { saml2Logout.relyingPartyRegistrationRepository(relyingPartyRegistrationRepository) } + logoutUrl?.also { saml2Logout.logoutUrl(logoutUrl) } + logoutRequest?.also { saml2Logout.logoutRequest(logoutRequest) } + logoutResponse?.also { saml2Logout.logoutResponse(logoutResponse) } + } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/LogoutRequestDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/LogoutRequestDsl.kt new file mode 100644 index 0000000000..0a07c15fb5 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/LogoutRequestDsl.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2024 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.saml2 + +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidator +import org.springframework.security.saml2.provider.service.web.authentication.logout.HttpSessionLogoutRequestRepository +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestRepository +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestResolver + +/** + * A Kotlin DSL to configure SAML 2.0 Logout Request components using idiomatic Kotlin code. + * + * @author Josh Cummings + * @since 6.3 + * @property logoutUrl The URL by which the asserting party can send a SAML 2.0 Logout Request. + * The Asserting Party should use whatever HTTP method specified in {@link RelyingPartyRegistration#getSingleLogoutServiceBindings()}. + * @property logoutRequestValidator the [Saml2LogoutRequestValidator] to use for validating incoming {@code LogoutRequest}s. + * @property logoutRequestResolver the [Saml2LogoutRequestResolver] to use for generating outgoing {@code LogoutRequest}s. + * @property logoutRequestRepository the [Saml2LogoutRequestRepository] to use for storing outgoing {@code LogoutRequest}s for + * linking to the corresponding {@code LogoutResponse} from the asserting party + */ +@Saml2SecurityMarker +class LogoutRequestDsl { + var logoutUrl = "/logout/saml2/slo" + var logoutRequestValidator: Saml2LogoutRequestValidator? = null + var logoutRequestResolver: Saml2LogoutRequestResolver? = null + var logoutRequestRepository: Saml2LogoutRequestRepository = HttpSessionLogoutRequestRepository() + + internal fun get(): (Saml2LogoutConfigurer.LogoutRequestConfigurer) -> Unit { + return { logoutRequest -> + logoutUrl.also { logoutRequest.logoutUrl(logoutUrl) } + logoutRequestValidator?.also { logoutRequest.logoutRequestValidator(logoutRequestValidator) } + logoutRequestResolver?.also { logoutRequest.logoutRequestResolver(logoutRequestResolver) } + logoutRequestRepository.also { logoutRequest.logoutRequestRepository(logoutRequestRepository) } + } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/LogoutResponseDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/LogoutResponseDsl.kt new file mode 100644 index 0000000000..dfc360c88d --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/LogoutResponseDsl.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2024 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.saml2 + +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidator +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseResolver + +/** + * A Kotlin DSL to configure SAML 2.0 Logout Response components using idiomatic Kotlin code. + * + * @author Josh Cummings + * @since 6.3 + * @property logoutUrl The URL by which the asserting party can send a SAML 2.0 Logout Response. + * The Asserting Party should use whatever HTTP method specified in {@link RelyingPartyRegistration#getSingleLogoutServiceBindings()}. + * @property logoutResponseValidator the [Saml2LogoutResponseValidator] to use for validating incoming {@code LogoutResponse}s. + * @property logoutResponseResolver the [Saml2LogoutResponseResolver] to use for generating outgoing {@code LogoutResponse}s. + */ +@Saml2SecurityMarker +class LogoutResponseDsl { + var logoutUrl = "/logout/saml2/slo" + var logoutResponseValidator: Saml2LogoutResponseValidator? = null + var logoutResponseResolver: Saml2LogoutResponseResolver? = null + + internal fun get(): (Saml2LogoutConfigurer.LogoutResponseConfigurer) -> Unit { + return { logoutResponse -> + logoutUrl.also { logoutResponse.logoutUrl(logoutUrl) } + logoutResponseValidator?.also { logoutResponse.logoutResponseValidator(logoutResponseValidator) } + logoutResponseResolver?.also { logoutResponse.logoutResponseResolver(logoutResponseResolver) } + } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/Saml2SecurityMarker.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/Saml2SecurityMarker.kt new file mode 100644 index 0000000000..bcf8df5b81 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/saml2/Saml2SecurityMarker.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 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.saml2 + +/** + * Marker annotation indicating that the annotated class is part of the SAML 2.0 logout security DSL. + * + * @author Josh Cummings + * @since 6.3 + */ +@DslMarker +annotation class Saml2SecurityMarker diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/Saml2LogoutDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/Saml2LogoutDslTests.kt new file mode 100644 index 0000000000..9e3e8fceca --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/Saml2LogoutDslTests.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2002-2024 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.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.security.authentication.TestAuthentication +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.core.authority.AuthorityUtils +import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors +import org.springframework.security.web.SecurityFilterChain +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import java.util.* + +/** + * Tests for [Saml2LogoutDsl] + * + * @author Josh Cummings + */ +@ExtendWith(SpringTestContextExtension::class) +class Saml2LogoutDslTests { + @JvmField + val spring = SpringTestContext(this) + + @Autowired + lateinit var mockMvc: MockMvc + + @Test + fun `saml2Logout when no relying party registration repository then exception`() { + Assertions.assertThatThrownBy { this.spring.register(Saml2LogoutNoRelyingPartyRegistrationRepoConfig::class.java).autowire() } + .isInstanceOf(BeanCreationException::class.java) + .hasMessageContaining("relyingPartyRegistrationRepository cannot be null") + + } + + @Test + @Throws(Exception::class) + fun `saml2Logout when defaults and not saml login then default logout`() { + this.spring.register(Saml2LogoutDefaultsConfig::class.java).autowire() + val user = TestAuthentication.authenticatedUser() + val result: MvcResult = this.mockMvc.perform( + MockMvcRequestBuilders.post("/logout").with(SecurityMockMvcRequestPostProcessors.authentication(user)) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(MockMvcResultMatchers.status().isFound()) + .andReturn() + val location = result.response.getHeader("Location") + Assertions.assertThat(location).isEqualTo("/login?logout") + } + + @Test + @Throws(Exception::class) + fun saml2LogoutWhenDefaultsThenLogsOutAndSendsLogoutRequest() { + this.spring.register(Saml2LogoutDefaultsConfig::class.java).autowire() + val principal = DefaultSaml2AuthenticatedPrincipal("user", emptyMap()) + principal.relyingPartyRegistrationId = "registration-id" + val user = Saml2Authentication(principal, "response", AuthorityUtils.createAuthorityList("ROLE_USER")) + val result: MvcResult = this.mockMvc.perform(MockMvcRequestBuilders.post("/logout") + .with(SecurityMockMvcRequestPostProcessors.authentication(user)) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(MockMvcResultMatchers.status().isFound()) + .andReturn() + val location = result.response.getHeader("Location") + Assertions.assertThat(location).startsWith("https://ap.example.org/logout/saml2/request") + } + + @Configuration + @EnableWebSecurity + open class Saml2LogoutNoRelyingPartyRegistrationRepoConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + saml2Logout { } + } + return http.build() + } + } + + @Configuration + @EnableWebSecurity + open class Saml2LogoutDefaultsConfig { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + saml2Logout { } + } + return http.build() + } + + @Bean + open fun registrations(): RelyingPartyRegistrationRepository = + InMemoryRelyingPartyRegistrationRepository(TestRelyingPartyRegistrations.full().build()) + + } + +}