diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2LoginConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2LoginConfiguration.java index 8d8e6770d7..56dd5be5d3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2LoginConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2LoginConfiguration.java @@ -38,6 +38,7 @@ class Saml2LoginConfiguration { @Bean SecurityFilterChain samlSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeRequests((requests) -> requests.anyRequest().authenticated()).saml2Login(); + http.saml2Logout(); return http.build(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java index 29a866f56f..bb00b28e42 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java @@ -65,6 +65,8 @@ public class Saml2RelyingPartyProperties { private final Decryption decryption = new Decryption(); + private final Singlelogout singlelogout = new Singlelogout(); + /** * Remote SAML Identity Provider. */ @@ -111,6 +113,10 @@ public class Saml2RelyingPartyProperties { return this.identityprovider; } + public Singlelogout getSinglelogout() { + return this.singlelogout; + } + public static class Acs { /** @@ -258,6 +264,8 @@ public class Saml2RelyingPartyProperties { private final Verification verification = new Verification(); + private final Singlelogout singlelogout = new Singlelogout(); + public String getEntityId() { return this.entityId; } @@ -282,6 +290,10 @@ public class Saml2RelyingPartyProperties { return this.verification; } + public Singlelogout getSinglelogout() { + return this.singlelogout; + } + /** * Single sign on details for an Identity Provider. */ @@ -372,4 +384,50 @@ public class Saml2RelyingPartyProperties { } + /** + * Single logout details. + */ + public static class Singlelogout { + + /** + * Location where SAML2 LogoutRequest gets sent to. + */ + private String url; + + /** + * Location where SAML2 LogoutResponse gets sent to. + */ + private String responseUrl; + + /** + * Whether to redirect or post logout requests. + */ + private Saml2MessageBinding binding; + + public String getUrl() { + return this.url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getResponseUrl() { + return this.responseUrl; + } + + public void setResponseUrl(String responseUrl) { + this.responseUrl = responseUrl; + } + + public Saml2MessageBinding getBinding() { + return this.binding; + } + + public void setBinding(Saml2MessageBinding binding) { + this.binding = binding; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java index 6d09a91715..b4be7d3590 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java @@ -96,6 +96,9 @@ class Saml2RelyingPartyRegistrationConfiguration { builder.assertingPartyDetails( (details) -> details.verificationX509Credentials((credentials) -> assertingParty.getVerification() .getCredentials().stream().map(this::asVerificationCredential).forEach(credentials::add))); + builder.singleLogoutServiceLocation(properties.getSinglelogout().getUrl()); + builder.singleLogoutServiceResponseLocation(properties.getSinglelogout().getResponseUrl()); + builder.singleLogoutServiceBinding(properties.getSinglelogout().getBinding()); builder.entityId(properties.getEntityId()); RelyingPartyRegistration registration = builder.build(); boolean signRequest = registration.getAssertingPartyDetails().getWantAuthnRequestsSigned(); @@ -113,6 +116,9 @@ class Saml2RelyingPartyRegistrationConfiguration { map.from(assertingParty::getSingleSignonUrl).to(details::singleSignOnServiceLocation); map.from(assertingParty::getSingleSignonSignRequest).when((ignored) -> !usingMetadata) .to(details::wantAuthnRequestsSigned); + map.from(assertingParty.getSinglelogoutUrl()).to(details::singleLogoutServiceLocation); + map.from(assertingParty.getSinglelogoutResponseUrl()).to(details::singleLogoutServiceResponseLocation); + map.from(assertingParty.getSinglelogoutBinding()).to(details::singleLogoutServiceBinding); }; } @@ -201,6 +207,18 @@ class Saml2RelyingPartyRegistrationConfiguration { return get("singlesignon.sign-request", (property) -> property.getSinglesignon().getSignRequest()); } + String getSinglelogoutUrl() { + return this.registration.getAssertingparty().getSinglelogout().getUrl(); + } + + String getSinglelogoutResponseUrl() { + return this.registration.getAssertingparty().getSinglelogout().getResponseUrl(); + } + + Saml2MessageBinding getSinglelogoutBinding() { + return this.registration.getAssertingparty().getSinglelogout().getBinding(); + } + @SuppressWarnings("deprecation") private T get(String name, Function getter) { T newValue = getter.apply(this.registration.getAssertingparty()); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java index 0ea9f63075..c37beea874 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java @@ -43,6 +43,7 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestFilter; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; @@ -65,7 +66,7 @@ class Saml2RelyingPartyAutoConfigurationTests { @Test void autoConfigurationShouldBeConditionalOnRelyingPartyRegistrationRepositoryClass() { this.contextRunner.withPropertyValues(getPropertyValues(false)).withClassLoader(new FilteredClassLoader( - "org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository")) + "org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository")) .run((context) -> assertThat(context).doesNotHaveBean(RelyingPartyRegistrationRepository.class)); } @@ -73,7 +74,7 @@ class Saml2RelyingPartyAutoConfigurationTests { @Deprecated void autoConfigurationShouldBeConditionalOnRelyingPartyRegistrationRepositoryClassDeprecated() { this.contextRunner.withPropertyValues(getPropertyValues(true)).withClassLoader(new FilteredClassLoader( - "org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository")) + "org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository")) .run((context) -> assertThat(context).doesNotHaveBean(RelyingPartyRegistrationRepository.class)); } @@ -144,6 +145,17 @@ class Saml2RelyingPartyAutoConfigurationTests { assertThat(registration.getDecryptionX509Credentials()).hasSize(1); assertThat(registration.getAssertingPartyDetails().getVerificationX509Credentials()).isNotNull(); assertThat(registration.getEntityId()).isEqualTo("{baseUrl}/saml2/foo-entity-id"); + assertThat(registration.getSingleLogoutServiceLocation()) + .isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SLOService.php"); + assertThat(registration.getSingleLogoutServiceResponseLocation()) + .isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/"); + assertThat(registration.getSingleLogoutServiceBinding()).isEqualTo(Saml2MessageBinding.POST); + assertThat(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation()) + .isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SLOService.php"); + assertThat(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation()) + .isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/"); + assertThat(registration.getAssertingPartyDetails().getSingleLogoutServiceBinding()) + .isEqualTo(Saml2MessageBinding.POST); }); } @@ -252,12 +264,12 @@ class Saml2RelyingPartyAutoConfigurationTests { setupMockResponse(server, new ClassPathResource("saml/idp-metadata")); this.contextRunner.withPropertyValues(PREFIX + ".foo.assertingparty.metadata-uri=" + metadataUrl, PREFIX + ".foo.assertingparty.singlesignon.binding=redirect").run((context) -> { - RelyingPartyRegistrationRepository repository = context - .getBean(RelyingPartyRegistrationRepository.class); - RelyingPartyRegistration registration = repository.findByRegistrationId("foo"); - assertThat(registration.getAssertingPartyDetails().getSingleSignOnServiceBinding()) - .isEqualTo(Saml2MessageBinding.REDIRECT); - }); + RelyingPartyRegistrationRepository repository = context + .getBean(RelyingPartyRegistrationRepository.class); + RelyingPartyRegistration registration = repository.findByRegistrationId("foo"); + assertThat(registration.getAssertingPartyDetails().getSingleSignOnServiceBinding()) + .isEqualTo(Saml2MessageBinding.REDIRECT); + }); } } @@ -270,12 +282,12 @@ class Saml2RelyingPartyAutoConfigurationTests { setupMockResponse(server, new ClassPathResource("saml/idp-metadata")); this.contextRunner.withPropertyValues(PREFIX + ".foo.identityprovider.metadata-uri=" + metadataUrl, PREFIX + ".foo.identityprovider.singlesignon.binding=redirect").run((context) -> { - RelyingPartyRegistrationRepository repository = context - .getBean(RelyingPartyRegistrationRepository.class); - RelyingPartyRegistration registration = repository.findByRegistrationId("foo"); - assertThat(registration.getAssertingPartyDetails().getSingleSignOnServiceBinding()) - .isEqualTo(Saml2MessageBinding.REDIRECT); - }); + RelyingPartyRegistrationRepository repository = context + .getBean(RelyingPartyRegistrationRepository.class); + RelyingPartyRegistration registration = repository.findByRegistrationId("foo"); + assertThat(registration.getAssertingPartyDetails().getSingleSignOnServiceBinding()) + .isEqualTo(Saml2MessageBinding.REDIRECT); + }); } } @@ -377,6 +389,12 @@ class Saml2RelyingPartyAutoConfigurationTests { .run((context) -> assertThat(context).doesNotHaveBean(SecurityFilterChain.class)); } + @Test + void samlLogoutShouldBeConfigured() { + this.contextRunner.withPropertyValues(getPropertyValues(false)) + .run((context) -> assertThat(hasFilter(context, Saml2LogoutRequestFilter.class)).isTrue()); + } + private String[] getPropertyValuesWithoutSigningCredentials(boolean signRequests, boolean useDeprecated) { String assertingParty = useDeprecated ? "identityprovider" : "assertingparty"; return new String[] { @@ -387,7 +405,7 @@ class Saml2RelyingPartyAutoConfigurationTests { PREFIX + ".foo." + assertingParty + ".entity-id=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php", PREFIX + ".foo." + assertingParty - + ".verification.credentials[0].certificate-location=classpath:saml/certificate-location" }; + + ".verification.credentials[0].certificate-location=classpath:saml/certificate-location"}; } private String[] getPropertyValuesWithoutSsoBinding(boolean useDeprecated) { @@ -399,7 +417,7 @@ class Saml2RelyingPartyAutoConfigurationTests { PREFIX + ".foo." + assertingParty + ".entity-id=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php", PREFIX + ".foo." + assertingParty - + ".verification.credentials[0].certificate-location=classpath:saml/certificate-location" }; + + ".verification.credentials[0].certificate-location=classpath:saml/certificate-location"}; } private String[] getPropertyValues(boolean useDeprecated) { @@ -409,6 +427,9 @@ class Saml2RelyingPartyAutoConfigurationTests { PREFIX + ".foo.signing.credentials[0].certificate-location=classpath:saml/certificate-location", PREFIX + ".foo.decryption.credentials[0].private-key-location=classpath:saml/private-key-location", PREFIX + ".foo.decryption.credentials[0].certificate-location=classpath:saml/certificate-location", + PREFIX + ".foo.singlelogout.url=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SLOService.php", + PREFIX + ".foo.singlelogout.response-url=https://simplesaml-for-spring-saml.cfapps.io/", + PREFIX + ".foo.singlelogout.binding=post", PREFIX + ".foo." + assertingParty + ".singlesignon.url=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php", PREFIX + ".foo." + assertingParty + ".singlesignon.binding=post", @@ -417,9 +438,12 @@ class Saml2RelyingPartyAutoConfigurationTests { + ".entity-id=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php", PREFIX + ".foo." + assertingParty + ".verification.credentials[0].certificate-location=classpath:saml/certificate-location", + PREFIX + ".foo.asserting-party.singlelogout.url=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SLOService.php", + PREFIX + ".foo.asserting-party.singlelogout.response-url=https://simplesaml-for-spring-saml.cfapps.io/", + PREFIX + ".foo.asserting-party.singlelogout.binding=post", PREFIX + ".foo.entity-id={baseUrl}/saml2/foo-entity-id", PREFIX + ".foo.acs.location={baseUrl}/login/saml2/foo-entity-id", - PREFIX + ".foo.acs.binding=redirect" }; + PREFIX + ".foo.acs.binding=redirect"}; } private boolean hasFilter(AssertableWebApplicationContext context, Class filter) { diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc index e82be55180..e46b02f0fc 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc @@ -262,6 +262,10 @@ You can register multiple relying parties under the `spring.security.saml2.relyi credentials: - private-key-location: "path-to-private-key" certificate-location: "path-to-certificate" + singlelogout: + url: "https://myapp/logout/saml2/slo" + reponse-url: "https://remoteidp2.slo.url" + binding: "POST" assertingparty: verification: credentials: @@ -284,4 +288,14 @@ You can register multiple relying parties under the `spring.security.saml2.relyi - certificate-location: "path-to-other-verification-cert" entity-id: "remote-idp-entity-id2" sso-url: "https://remoteidp2.sso.url" + singlelogout: + url: "https://remoteidp2.slo.url" + reponse-url: "https://myapp/logout/saml2/slo" + binding: "POST" ---- + +For SAML2 logout, by default, Spring Security's `Saml2LogoutRequestFilter` and `Saml2LogoutResponseFilter` only process URLs matching `/logout/saml2/slo`. +If you want to customize the `url` to which AP-initiated logout requests get sent to or the `response-url` to which an AP sends logout responses to, to use a different pattern, you need to provide configuration to process that custom pattern. +For example, for servlet applications, you can add your own `SecurityFilterChain` that resembles the following: + +include::code:MySamlRelyingPartyConfiguration[] diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/security/saml2/relyingparty/MySamlRelyingPartyConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/security/saml2/relyingparty/MySamlRelyingPartyConfiguration.java new file mode 100644 index 0000000000..6cd3bc68f3 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/security/saml2/relyingparty/MySamlRelyingPartyConfiguration.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-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.boot.docs.web.security.saml2.relyingparty; + +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.web.SecurityFilterChain; + +@Configuration(proxyBeanMethods = false) +public class MySamlRelyingPartyConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeRequests().anyRequest().authenticated(); + http.saml2Login(); + http.saml2Logout((saml2) -> saml2.logoutRequest((request) -> request.logoutUrl("/SLOService.saml2")) + .logoutResponse((response) -> response.logoutUrl("/SLOService.saml2"))); + return http.build(); + } + +}