From 38e3fa229704e890609a4c4600846aedec44c8bb Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Mon, 16 Aug 2021 15:22:29 -0600 Subject: [PATCH 1/5] useJUnitPlatform for SAML 2.0 Tests Issue gh-9467 --- .../spring-security-saml2-service-provider.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle b/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle index 97b5547b203..aecc7a6044d 100644 --- a/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle +++ b/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle @@ -82,3 +82,11 @@ project.tasks.matching { t -> t.name == "sourcesJar"}.configureEach { javadoc { source += sourceSets.opensaml3Main.allJava + sourceSets.opensaml4Main.allJava } + +opensaml3Test { + useJUnitPlatform() +} + +opensaml4Test { + useJUnitPlatform() +} From a1286371fbc0486ced9c50d61cd63ef0da1054ba Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 2 Mar 2021 07:54:18 -0700 Subject: [PATCH 2/5] Add Registration to Saml2Authentication Closes gh-9487 --- .../_includes/servlet/saml2/saml2-login.adoc | 6 ++++++ .../DefaultSaml2AuthenticatedPrincipal.java | 13 +++++++++++++ .../authentication/Saml2AuthenticatedPrincipal.java | 10 ++++++++++ .../service/authentication/Saml2Authentication.java | 6 ++++++ .../OpenSamlAuthenticationProvider.java | 13 +++++++++---- .../OpenSaml4AuthenticationProvider.java | 7 +++++-- 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc index 2a95f546bc9..303351fb797 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc @@ -107,6 +107,7 @@ where * `https://idp.example.com/issuer` is the value contained in the `Issuer` attribute of the SAML responses that the identity provider will issue * `classpath:idp.crt` is the location on the classpath for the identity provider's certificate for verifying SAML responses, and * `https://idp.example.com/issuer/sso` is the endpoint where the identity provider is expecting `AuthnRequest` s. +* `adfs` is <> And that's it! @@ -196,6 +197,7 @@ image:{icondir}/number_10.png[] And finally, it takes the `NameID` from the firs Then, it places that principal and the authorities into a `Saml2Authentication`. The resulting `Authentication#getPrincipal` is a Spring Security `Saml2AuthenticatedPrincipal` object, and `Authentication#getName` maps to the first assertion's `NameID` element. +`Saml2AuthenticatedPrincipal#getRelyingPartyRegistrationId` holds the <>. [[servlet-saml2login-opensaml-customization]] ==== Customizing OpenSAML Configuration @@ -410,6 +412,10 @@ open fun relyingPartyRegistrations(): RelyingPartyRegistrationRepository? { ---- ==== +[[servlet-saml2login-relyingpartyregistrationid]] +[NOTE] +The `registrationId` is an arbitrary value that you choose for differentiating between registrations. + Or you can provide each detail manually, as you can see below: .Relying Party Registration Repository Manual Configuration diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java index 8e1ac9270b2..22c12e1ebf5 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java @@ -34,11 +34,14 @@ public class DefaultSaml2AuthenticatedPrincipal implements Saml2AuthenticatedPri private final Map> attributes; + private String registrationId; + public DefaultSaml2AuthenticatedPrincipal(String name, Map> attributes) { Assert.notNull(name, "name cannot be null"); Assert.notNull(attributes, "attributes cannot be null"); this.name = name; this.attributes = attributes; + this.registrationId = null; } @Override @@ -51,4 +54,14 @@ public Map> getAttributes() { return this.attributes; } + @Override + public String getRelyingPartyRegistrationId() { + return this.registrationId; + } + + public void setRelyingPartyRegistrationId(String registrationId) { + Assert.notNull(registrationId, "relyingPartyRegistrationId cannot be null"); + this.registrationId = registrationId; + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticatedPrincipal.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticatedPrincipal.java index 5996b0a4c5d..c40015f94f8 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticatedPrincipal.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticatedPrincipal.java @@ -22,6 +22,7 @@ import org.springframework.lang.Nullable; import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.util.CollectionUtils; /** @@ -66,4 +67,13 @@ default Map> getAttributes() { return Collections.emptyMap(); } + /** + * Get the {@link RelyingPartyRegistration} identifier + * @return the {@link RelyingPartyRegistration} identifier + * @since 5.6 + */ + default String getRelyingPartyRegistrationId() { + return null; + } + } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java index d37792456bb..d32c1f44697 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java @@ -41,6 +41,12 @@ public class Saml2Authentication extends AbstractAuthenticationToken { private final String saml2Response; + /** + * Construct a {@link Saml2Authentication} using the provided parameters + * @param principal the logged in user + * @param saml2Response the SAML 2.0 response used to authenticate the user + * @param authorities the authorities for the logged in user + */ public Saml2Authentication(AuthenticatedPrincipal principal, String saml2Response, Collection authorities) { super(authorities); diff --git a/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java b/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java index e8531b1d397..18ec5f77ebb 100644 --- a/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java @@ -424,8 +424,11 @@ public static Converter createDefaultRespons Assertion assertion = CollectionUtils.firstElement(response.getAssertions()); String username = assertion.getSubject().getNameID().getValue(); Map> attributes = getAssertionAttributes(assertion); - return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal(username, attributes), - token.getSaml2Response(), Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"))); + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal(username, attributes); + String registrationId = responseToken.token.getRelyingPartyRegistration().getRegistrationId(); + principal.setRelyingPartyRegistrationId(registrationId); + return new Saml2Authentication(principal, token.getSaml2Response(), + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"))); }; } @@ -626,8 +629,10 @@ private Converter createCompatibleResponseAu Assertion assertion = CollectionUtils.firstElement(response.getAssertions()); String username = assertion.getSubject().getNameID().getValue(); Map> attributes = getAssertionAttributes(assertion); - return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal(username, attributes), - token.getSaml2Response(), + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal(username, attributes); + String registrationId = responseToken.token.getRelyingPartyRegistration().getRegistrationId(); + principal.setRelyingPartyRegistrationId(registrationId); + return new Saml2Authentication(principal, token.getSaml2Response(), this.authoritiesMapper.mapAuthorities(getAssertionAuthorities(assertion))); }; } diff --git a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java index 74f5cde0d61..d83830e5939 100644 --- a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java @@ -425,8 +425,11 @@ public static Converter createDefaultRespons Assertion assertion = CollectionUtils.firstElement(response.getAssertions()); String username = assertion.getSubject().getNameID().getValue(); Map> attributes = getAssertionAttributes(assertion); - return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal(username, attributes), - token.getSaml2Response(), AuthorityUtils.createAuthorityList("ROLE_USER")); + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal(username, attributes); + String registrationId = responseToken.token.getRelyingPartyRegistration().getRegistrationId(); + principal.setRelyingPartyRegistrationId(registrationId); + return new Saml2Authentication(principal, token.getSaml2Response(), + AuthorityUtils.createAuthorityList("ROLE_USER")); }; } From 367f07413e51db0a4487225cd382418f981ee61f Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 2 Mar 2021 07:55:05 -0700 Subject: [PATCH 3/5] Add RelyingPartyRegistrationResolver Closes gh-9486 --- .../saml2/Saml2LoginConfigurer.java | 9 ++-- .../_includes/servlet/saml2/saml2-login.adoc | 25 +++++----- .../Saml2WebSsoAuthenticationFilter.java | 5 +- ...aml2WebSsoAuthenticationRequestFilter.java | 5 +- ...faultRelyingPartyRegistrationResolver.java | 48 ++++++++++++------- ...2AuthenticationRequestContextResolver.java | 13 +++++ .../web/RelyingPartyRegistrationResolver.java | 40 ++++++++++++++++ .../Saml2AuthenticationTokenConverter.java | 14 ++++++ .../service/web/Saml2MetadataFilter.java | 30 +++++++++--- .../Saml2WebSsoAuthenticationFilterTests.java | 34 +++++++++++++ ...ebSsoAuthenticationRequestFilterTests.java | 31 ++++++++++++ ...enticationRequestContextResolverTests.java | 5 +- ...aml2AuthenticationTokenConverterTests.java | 3 +- .../service/web/Saml2MetadataFilterTests.java | 21 +++++++- 14 files changed, 239 insertions(+), 44 deletions(-) create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/RelyingPartyRegistrationResolver.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java index 3b8220e80ed..9860e8040b1 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -47,6 +47,7 @@ import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter; import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.DefaultSaml2AuthenticationRequestContextResolver; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestContextResolver; import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationTokenConverter; import org.springframework.security.web.authentication.AuthenticationConverter; @@ -264,7 +265,8 @@ private void setAuthenticationRequestRepository(B http, private AuthenticationConverter getAuthenticationConverter(B http) { if (this.authenticationConverter == null) { return new Saml2AuthenticationTokenConverter( - new DefaultRelyingPartyRegistrationResolver(this.relyingPartyRegistrationRepository)); + (RelyingPartyRegistrationResolver) new DefaultRelyingPartyRegistrationResolver( + this.relyingPartyRegistrationRepository)); } return this.authenticationConverter; } @@ -390,8 +392,9 @@ private Saml2AuthenticationRequestContextResolver getContextResolver(B http) { Saml2AuthenticationRequestContextResolver resolver = getBeanOrNull(http, Saml2AuthenticationRequestContextResolver.class); if (resolver == null) { - return new DefaultSaml2AuthenticationRequestContextResolver(new DefaultRelyingPartyRegistrationResolver( - Saml2LoginConfigurer.this.relyingPartyRegistrationRepository)); + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = new DefaultRelyingPartyRegistrationResolver( + Saml2LoginConfigurer.this.relyingPartyRegistrationRepository); + return new DefaultSaml2AuthenticationRequestContextResolver(relyingPartyRegistrationResolver); } return resolver; } diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc index 303351fb797..33e7c5ad35a 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc @@ -727,7 +727,7 @@ There are a number of reasons you may want to customize. Among them: * You may know that you will never be a multi-tenant application and so want to have a simpler URL scheme * You may identify tenants in a way other than by the URI path -To customize the way that a `RelyingPartyRegistration` is resolved, you can configure a custom `Converter`. +To customize the way that a `RelyingPartyRegistration` is resolved, you can configure a custom `RelyingPartyRegistrationResolver`. The default looks up the registration id from the URI's last path element and looks it up in your `RelyingPartyRegistrationRepository`. You can provide a simpler resolver that, for example, always returns the same relying party: @@ -736,12 +736,17 @@ You can provide a simpler resolver that, for example, always returns the same re .Java [source,java,role="primary"] ---- -public class SingleRelyingPartyRegistrationResolver - implements Converter { +public class SingleRelyingPartyRegistrationResolver implements RelyingPartyRegistrationResolver { + + private final RelyingPartyRegistrationResolver delegate; + + public SingleRelyingPartyRegistrationResolver(RelyingPartyRegistrationRepository registrations) { + this.delegate = new DefaultRelyingPartyRegistrationResolver(registrations); + } @Override - public RelyingPartyRegistration convert(HttpServletRequest request) { - return this.relyingParty; + public RelyingPartyRegistration resolve(HttpServletRequest request, String registrationId) { + return this.delegate.resolve(request, "single"); } } ---- @@ -749,9 +754,9 @@ public class SingleRelyingPartyRegistrationResolver .Kotlin [source,kotlin,role="secondary"] ---- -class SingleRelyingPartyRegistrationResolver : Converter { - override fun convert(request: HttpServletRequest?): RelyingPartyRegistration? { - return this.relyingParty +class SingleRelyingPartyRegistrationResolver(delegate: RelyingPartyRegistrationResolver) : RelyingPartyRegistrationResolver { + override fun resolve(request: HttpServletRequest?, registrationId: String?): RelyingPartyRegistration? { + return this.delegate.resolve(request, "single") } } ---- @@ -1544,7 +1549,7 @@ You can publish a metadata endpoint by adding the `Saml2MetadataFilter` to the f .Java [source,java,role="primary"] ---- -Converter relyingPartyRegistrationResolver = +DefaultRelyingPartyRegistrationResolver relyingPartyRegistrationResolver = new DefaultRelyingPartyRegistrationResolver(this.relyingPartyRegistrationRepository); Saml2MetadataFilter filter = new Saml2MetadataFilter( relyingPartyRegistrationResolver, @@ -1594,8 +1599,6 @@ filter.setRequestMatcher(AntPathRequestMatcher("/saml2/metadata/{registrationId} ---- ==== -ensuring that the `registrationId` hint is at the end of the path. - 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: ==== diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java index c59ec4deebf..b5fc9e01b3d 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java @@ -29,6 +29,7 @@ import org.springframework.security.saml2.provider.service.servlet.HttpSessionSaml2AuthenticationRequestRepository; import org.springframework.security.saml2.provider.service.servlet.Saml2AuthenticationRequestRepository; import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationTokenConverter; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.authentication.AuthenticationConverter; @@ -67,7 +68,9 @@ public Saml2WebSsoAuthenticationFilter(RelyingPartyRegistrationRepository relyin public Saml2WebSsoAuthenticationFilter(RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, String filterProcessesUrl) { this(new Saml2AuthenticationTokenConverter( - new DefaultRelyingPartyRegistrationResolver(relyingPartyRegistrationRepository)), filterProcessesUrl); + (RelyingPartyRegistrationResolver) new DefaultRelyingPartyRegistrationResolver( + relyingPartyRegistrationRepository)), + filterProcessesUrl); } /** diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilter.java index 39819a513af..b1ceadd08fa 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilter.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilter.java @@ -39,6 +39,7 @@ import org.springframework.security.saml2.provider.service.servlet.Saml2AuthenticationRequestRepository; import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.DefaultSaml2AuthenticationRequestContextResolver; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestContextResolver; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -96,7 +97,9 @@ public class Saml2WebSsoAuthenticationRequestFilter extends OncePerRequestFilter public Saml2WebSsoAuthenticationRequestFilter( RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { this(new DefaultSaml2AuthenticationRequestContextResolver( - new DefaultRelyingPartyRegistrationResolver(relyingPartyRegistrationRepository)), requestFactory()); + (RelyingPartyRegistrationResolver) new DefaultRelyingPartyRegistrationResolver( + relyingPartyRegistrationRepository)), + requestFactory()); } private static Saml2AuthenticationRequestFactory requestFactory() { diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java index 10b667847c7..ce8ae7e4489 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java @@ -22,6 +22,9 @@ import javax.servlet.http.HttpServletRequest; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + import org.springframework.core.convert.converter.Converter; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; @@ -42,13 +45,15 @@ * @since 5.4 */ public final class DefaultRelyingPartyRegistrationResolver - implements Converter { + implements RelyingPartyRegistrationResolver, Converter { + + private Log logger = LogFactory.getLog(getClass()); private static final char PATH_DELIMITER = '/'; private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; - private final Converter registrationIdResolver = new RegistrationIdResolver(); + private final RequestMatcher registrationRequestMatcher = new AntPathRequestMatcher("/**/{registrationId}"); public DefaultRelyingPartyRegistrationResolver( RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { @@ -56,14 +61,35 @@ public DefaultRelyingPartyRegistrationResolver( this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository; } + /** + * {@inheritDoc} + */ @Override public RelyingPartyRegistration convert(HttpServletRequest request) { - String registrationId = this.registrationIdResolver.convert(request); - if (registrationId == null) { + return resolve(request, null); + } + + /** + * {@inheritDoc} + */ + @Override + public RelyingPartyRegistration resolve(HttpServletRequest request, String relyingPartyRegistrationId) { + if (relyingPartyRegistrationId == null) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Attempting to resolve from " + this.registrationRequestMatcher + + " since registrationId is null"); + } + relyingPartyRegistrationId = this.registrationRequestMatcher.matcher(request).getVariables() + .get("registrationId"); + } + if (relyingPartyRegistrationId == null) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Returning null registration since registrationId is null"); + } return null; } RelyingPartyRegistration relyingPartyRegistration = this.relyingPartyRegistrationRepository - .findByRegistrationId(registrationId); + .findByRegistrationId(relyingPartyRegistrationId); if (relyingPartyRegistration == null) { return null; } @@ -111,16 +137,4 @@ private static String getApplicationUri(HttpServletRequest request) { return uriComponents.toUriString(); } - private static class RegistrationIdResolver implements Converter { - - private final RequestMatcher requestMatcher = new AntPathRequestMatcher("/**/{registrationId}"); - - @Override - public String convert(HttpServletRequest request) { - RequestMatcher.MatchResult result = this.requestMatcher.matcher(request); - return result.getVariables().get("registrationId"); - } - - } - } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultSaml2AuthenticationRequestContextResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultSaml2AuthenticationRequestContextResolver.java index a6cdb3ed918..d95472e8e33 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultSaml2AuthenticationRequestContextResolver.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultSaml2AuthenticationRequestContextResolver.java @@ -42,11 +42,24 @@ public final class DefaultSaml2AuthenticationRequestContextResolver private final Converter relyingPartyRegistrationResolver; + /** + * Construct a {@link DefaultSaml2AuthenticationRequestContextResolver} + * @param relyingPartyRegistrationResolver + * @deprecated Use + * {@link DefaultSaml2AuthenticationRequestContextResolver#DefaultSaml2AuthenticationRequestContextResolver(RelyingPartyRegistrationResolver)} + * instead + */ + @Deprecated public DefaultSaml2AuthenticationRequestContextResolver( Converter relyingPartyRegistrationResolver) { this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; } + public DefaultSaml2AuthenticationRequestContextResolver( + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.relyingPartyRegistrationResolver = (request) -> relyingPartyRegistrationResolver.resolve(request, null); + } + @Override public Saml2AuthenticationRequestContext resolve(HttpServletRequest request) { Assert.notNull(request, "request cannot be null"); diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/RelyingPartyRegistrationResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/RelyingPartyRegistrationResolver.java new file mode 100644 index 00000000000..d9e5e0eb14f --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/RelyingPartyRegistrationResolver.java @@ -0,0 +1,40 @@ +/* + * 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.saml2.provider.service.web; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; + +/** + * A contract for resolving a {@link RelyingPartyRegistration} from the HTTP request + * + * @author Josh Cummings + * @since 5.6 + */ +public interface RelyingPartyRegistrationResolver { + + /** + * Resolve a {@link RelyingPartyRegistration} from the HTTP request, using the + * {@code relyingPartyRegistrationId}, if it is provided + * @param request the HTTP request + * @param relyingPartyRegistrationId the {@link RelyingPartyRegistration} identifier + * @return the resolved {@link RelyingPartyRegistration} + */ + RelyingPartyRegistration resolve(HttpServletRequest request, String relyingPartyRegistrationId); + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2AuthenticationTokenConverter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2AuthenticationTokenConverter.java index 91f8f3e95c9..d0dfa986e91 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2AuthenticationTokenConverter.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2AuthenticationTokenConverter.java @@ -61,7 +61,11 @@ public final class Saml2AuthenticationTokenConverter implements AuthenticationCo * resolving {@link RelyingPartyRegistration}s * @param relyingPartyRegistrationResolver the strategy for resolving * {@link RelyingPartyRegistration}s + * @deprecated Use + * {@link Saml2AuthenticationTokenConverter#Saml2AuthenticationTokenConverter(RelyingPartyRegistrationResolver)} + * instead */ + @Deprecated public Saml2AuthenticationTokenConverter( Converter relyingPartyRegistrationResolver) { Assert.notNull(relyingPartyRegistrationResolver, "relyingPartyRegistrationResolver cannot be null"); @@ -69,6 +73,16 @@ public Saml2AuthenticationTokenConverter( this.loader = new HttpSessionSaml2AuthenticationRequestRepository()::loadAuthenticationRequest; } + public Saml2AuthenticationTokenConverter(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this(adaptToConverter(relyingPartyRegistrationResolver)); + } + + private static Converter adaptToConverter( + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + Assert.notNull(relyingPartyRegistrationResolver, "relyingPartyRegistrationResolver cannot be null"); + return (request) -> relyingPartyRegistrationResolver.resolve(request, null); + } + @Override public Saml2AuthenticationToken convert(HttpServletRequest request) { RelyingPartyRegistration relyingPartyRegistration = this.relyingPartyRegistrationResolver.convert(request); diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilter.java index 57ec493bc8b..f01ce2d4301 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilter.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilter.java @@ -46,7 +46,7 @@ public final class Saml2MetadataFilter extends OncePerRequestFilter { public static final String DEFAULT_METADATA_FILE_NAME = "saml-{registrationId}-metadata.xml"; - private final Converter relyingPartyRegistrationConverter; + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; private final Saml2MetadataResolver saml2MetadataResolver; @@ -55,11 +55,26 @@ public final class Saml2MetadataFilter extends OncePerRequestFilter { private RequestMatcher requestMatcher = new AntPathRequestMatcher( "/saml2/service-provider-metadata/{registrationId}"); - public Saml2MetadataFilter( - Converter relyingPartyRegistrationConverter, + /** + * Construct a {@link Saml2MetadataFilter} + * @param relyingPartyRegistrationResolver + * @param saml2MetadataResolver + * @deprecated Use + * {@link Saml2MetadataFilter#Saml2MetadataFilter(RelyingPartyRegistrationResolver)} + * instead + */ + @Deprecated + public Saml2MetadataFilter(Converter relyingPartyRegistrationResolver, Saml2MetadataResolver saml2MetadataResolver) { + this.relyingPartyRegistrationResolver = (request, id) -> relyingPartyRegistrationResolver.convert(request); + this.saml2MetadataResolver = saml2MetadataResolver; + } - this.relyingPartyRegistrationConverter = relyingPartyRegistrationConverter; + public Saml2MetadataFilter(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver, + Saml2MetadataResolver saml2MetadataResolver) { + Assert.notNull(relyingPartyRegistrationResolver, "relyingPartyRegistrationResolver cannot be null"); + Assert.notNull(saml2MetadataResolver, "saml2MetadataResolver cannot be null"); + this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; this.saml2MetadataResolver = saml2MetadataResolver; } @@ -71,14 +86,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse chain.doFilter(request, response); return; } - RelyingPartyRegistration relyingPartyRegistration = this.relyingPartyRegistrationConverter.convert(request); + String registrationId = matcher.getVariables().get("registrationId"); + RelyingPartyRegistration relyingPartyRegistration = this.relyingPartyRegistrationResolver.resolve(request, + registrationId); if (relyingPartyRegistration == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } String metadata = this.saml2MetadataResolver.resolve(relyingPartyRegistration); - String registrationId = relyingPartyRegistration.getRegistrationId(); - writeMetadataToResponse(response, registrationId, metadata); + writeMetadataToResponse(response, relyingPartyRegistration.getRegistrationId(), metadata); } private void writeMetadataToResponse(HttpServletResponse response, String registrationId, String metadata) diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilterTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilterTests.java index 11b07fd2fc9..914f3701548 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilterTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilterTests.java @@ -22,15 +22,25 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.security.saml2.provider.service.authentication.TestSaml2AuthenticationTokens; +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.servlet.Saml2AuthenticationRequestRepository; +import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationTokenConverter; import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -49,6 +59,8 @@ public class Saml2WebSsoAuthenticationFilterTests { private HttpServletResponse response = new MockHttpServletResponse(); + private AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + @BeforeEach public void setup() { this.filter = new Saml2WebSsoAuthenticationFilter(this.repository); @@ -132,4 +144,26 @@ public void setAuthenticationRequestRepositoryWhenNotExpectedAuthenticationConve verifyNoInteractions(authenticationConverter); } + @Test + public void doFilterWhenPathStartsWithRegistrationIdThenAuthenticates() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + Authentication authentication = new TestingAuthenticationToken("user", "password"); + given(this.repository.findByRegistrationId("registration-id")).willReturn(registration); + given(this.authenticationManager.authenticate(authentication)).willReturn(authentication); + String loginProcessingUrl = "/{registrationId}/login/saml2/sso"; + RequestMatcher matcher = new AntPathRequestMatcher(loginProcessingUrl); + DefaultRelyingPartyRegistrationResolver delegate = new DefaultRelyingPartyRegistrationResolver(this.repository); + RelyingPartyRegistrationResolver resolver = (request, id) -> { + String registrationId = matcher.matcher(request).getVariables().get("registrationId"); + return delegate.resolve(request, registrationId); + }; + Saml2AuthenticationTokenConverter authenticationConverter = new Saml2AuthenticationTokenConverter(resolver); + this.filter = new Saml2WebSsoAuthenticationFilter(authenticationConverter, loginProcessingUrl); + this.filter.setAuthenticationManager(this.authenticationManager); + this.request.setPathInfo("/registration-id/login/saml2/sso"); + this.request.setParameter("SAMLResponse", "response"); + this.filter.doFilter(this.request, this.response, new MockFilterChain()); + verify(this.repository).findByRegistrationId("registration-id"); + } + } diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilterTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilterTests.java index 0eda04f2674..5afcc554b12 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilterTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationRequestFilterTests.java @@ -37,8 +37,14 @@ 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.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; import org.springframework.security.saml2.provider.service.servlet.Saml2AuthenticationRequestRepository; +import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.DefaultSaml2AuthenticationRequestContextResolver; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestContextResolver; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.util.HtmlUtils; import org.springframework.web.util.UriUtils; @@ -256,4 +262,29 @@ public void doFilterWhenPostThenSaveRedirectRequest() throws ServletException, I any(Saml2PostAuthenticationRequest.class), eq(this.request), eq(this.response)); } + @Test + public void doFilterWhenPathStartsWithRegistrationIdThenPosts() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full() + .assertingPartyDetails((party) -> party.singleSignOnServiceBinding(Saml2MessageBinding.POST)).build(); + RequestMatcher matcher = new AntPathRequestMatcher("/{registrationId}/saml2/authenticate"); + DefaultRelyingPartyRegistrationResolver delegate = new DefaultRelyingPartyRegistrationResolver(this.repository); + RelyingPartyRegistrationResolver resolver = (request, id) -> { + String registrationId = matcher.matcher(request).getVariables().get("registrationId"); + return delegate.resolve(request, registrationId); + }; + Saml2AuthenticationRequestContextResolver authenticationRequestContextResolver = new DefaultSaml2AuthenticationRequestContextResolver( + resolver); + Saml2PostAuthenticationRequest authenticationRequest = mock(Saml2PostAuthenticationRequest.class); + given(authenticationRequest.getAuthenticationRequestUri()).willReturn("uri"); + given(authenticationRequest.getRelayState()).willReturn("relay"); + given(authenticationRequest.getSamlRequest()).willReturn("saml"); + given(this.repository.findByRegistrationId("registration-id")).willReturn(registration); + given(this.factory.createPostAuthenticationRequest(any())).willReturn(authenticationRequest); + this.filter = new Saml2WebSsoAuthenticationRequestFilter(authenticationRequestContextResolver, this.factory); + this.filter.setRedirectMatcher(matcher); + this.request.setPathInfo("/registration-id/saml2/authenticate"); + this.filter.doFilter(this.request, this.response, new MockFilterChain()); + verify(this.repository).findByRegistrationId("registration-id"); + } + } diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultSaml2AuthenticationRequestContextResolverTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultSaml2AuthenticationRequestContextResolverTests.java index 273fe0484e0..b73d7d65ab4 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultSaml2AuthenticationRequestContextResolverTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultSaml2AuthenticationRequestContextResolverTests.java @@ -49,8 +49,11 @@ public class DefaultSaml2AuthenticationRequestContextResolverTests { private RelyingPartyRegistration.Builder relyingPartyBuilder; + private RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = new DefaultRelyingPartyRegistrationResolver( + (id) -> this.relyingPartyBuilder.build()); + private Saml2AuthenticationRequestContextResolver authenticationRequestContextResolver = new DefaultSaml2AuthenticationRequestContextResolver( - new DefaultRelyingPartyRegistrationResolver((id) -> this.relyingPartyBuilder.build())); + this.relyingPartyRegistrationResolver); @BeforeEach public void setup() { diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2AuthenticationTokenConverterTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2AuthenticationTokenConverterTests.java index e922dcc699c..9fe6aef59b0 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2AuthenticationTokenConverterTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2AuthenticationTokenConverterTests.java @@ -176,7 +176,8 @@ public void convertWhenSavedAuthenticationRequestThenToken() { @Test public void constructorWhenResolverIsNullThenIllegalArgument() { - assertThatIllegalArgumentException().isThrownBy(() -> new Saml2AuthenticationTokenConverter(null)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new Saml2AuthenticationTokenConverter((RelyingPartyRegistrationResolver) null)); } @Test diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilterTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilterTests.java index ced8ad5a877..0f40eebdf33 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilterTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilterTests.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.saml2.core.TestSaml2X509Credentials; @@ -37,6 +38,7 @@ 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.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -63,8 +65,9 @@ public class Saml2MetadataFilterTests { public void setup() { this.repository = mock(RelyingPartyRegistrationRepository.class); this.resolver = mock(Saml2MetadataResolver.class); - this.filter = new Saml2MetadataFilter(new DefaultRelyingPartyRegistrationResolver(this.repository), - this.resolver); + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = new DefaultRelyingPartyRegistrationResolver( + this.repository); + this.filter = new Saml2MetadataFilter(relyingPartyRegistrationResolver, this.resolver); this.request = new MockHttpServletRequest(); this.response = new MockHttpServletResponse(); this.chain = mock(FilterChain.class); @@ -136,6 +139,20 @@ public void doFilterWhenSetMetadataFilenameThenUses() throws Exception { .isEqualTo("attachment; filename=\"%s\"; filename*=UTF-8''%s", fileName, encodedFileName); } + @Test + public void doFilterWhenPathStartsWithRegistrationIdThenServesMetadata() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + given(this.repository.findByRegistrationId("registration-id")).willReturn(registration); + given(this.resolver.resolve(any())).willReturn("metadata"); + RelyingPartyRegistrationResolver resolver = new DefaultRelyingPartyRegistrationResolver( + (id) -> this.repository.findByRegistrationId("registration-id")); + this.filter = new Saml2MetadataFilter(resolver, this.resolver); + this.filter.setRequestMatcher(new AntPathRequestMatcher("/metadata")); + this.request.setPathInfo("/metadata"); + this.filter.doFilter(this.request, this.response, new MockFilterChain()); + verify(this.repository).findByRegistrationId("registration-id"); + } + @Test public void setRequestMatcherWhenNullThenIllegalArgument() { assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setRequestMatcher(null)); From d5d591173fa5a79f72395c99e9b1866fab44b1da Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 30 Mar 2021 17:02:27 -0600 Subject: [PATCH 4/5] Add Single Logout Support Closes gh-8731 --- .../_includes/servlet/saml2/saml2-login.adoc | 278 +++++++++++++++++- .../security/saml2/core/Saml2ErrorCodes.java | 7 + .../OpenSamlLogoutRequestValidator.java | 175 +++++++++++ .../OpenSamlLogoutResponseValidator.java | 187 ++++++++++++ .../logout/OpenSamlVerificationUtils.java | 247 ++++++++++++++++ .../logout/Saml2LogoutRequest.java | 248 ++++++++++++++++ .../logout/Saml2LogoutRequestValidator.java | 38 +++ ...Saml2LogoutRequestValidatorParameters.java | 73 +++++ .../logout/Saml2LogoutResponse.java | 207 +++++++++++++ .../logout/Saml2LogoutResponseValidator.java | 38 +++ ...aml2LogoutResponseValidatorParameters.java | 72 +++++ .../logout/Saml2LogoutValidatorResult.java | 106 +++++++ .../authentication/logout/Saml2Utils.java | 76 +++++ .../metadata/OpenSamlMetadataResolver.java | 10 + ...enSamlAssertingPartyMetadataConverter.java | 28 +- .../RelyingPartyRegistration.java | 256 +++++++++++++++- ...faultRelyingPartyRegistrationResolver.java | 12 +- .../HttpSessionLogoutRequestRepository.java | 105 +++++++ .../logout/OpenSamlLogoutRequestResolver.java | 167 +++++++++++ .../OpenSamlLogoutResponseResolver.java | 218 ++++++++++++++ .../logout/OpenSamlSigningUtils.java | 173 +++++++++++ .../logout/Saml2LogoutRequestFilter.java | 250 ++++++++++++++++ .../logout/Saml2LogoutRequestRepository.java | 68 +++++ .../logout/Saml2LogoutRequestResolver.java | 49 +++ .../logout/Saml2LogoutResponseFilter.java | 169 +++++++++++ .../logout/Saml2LogoutResponseResolver.java | 47 +++ ...ingPartyInitiatedLogoutSuccessHandler.java | 171 +++++++++++ .../web/authentication/logout/Saml2Utils.java | 76 +++++ .../OpenSaml3LogoutRequestResolver.java | 125 ++++++++ .../OpenSaml3LogoutResponseResolver.java | 121 ++++++++ .../OpenSaml3LogoutRequestResolverTests.java | 65 ++++ .../OpenSaml3LogoutResponseResolverTests.java | 74 +++++ .../OpenSaml4LogoutRequestResolver.java | 123 ++++++++ .../OpenSaml4LogoutResponseResolver.java | 119 ++++++++ .../OpenSaml4LogoutRequestResolverTests.java | 65 ++++ .../OpenSaml4LogoutResponseResolverTests.java | 74 +++++ .../authentication/TestOpenSamlObjects.java | 46 ++- .../OpenSamlLogoutRequestValidatorTests.java | 173 +++++++++++ .../OpenSamlLogoutResponseValidatorTests.java | 158 ++++++++++ .../logout/OpenSamlSigningUtils.java | 173 +++++++++++ .../OpenSamlMetadataResolverTests.java | 6 +- .../TestRelyingPartyRegistrations.java | 12 +- ...RelyingPartyRegistrationResolverTests.java | 3 + ...tpSessionLogoutRequestRepositoryTests.java | 229 +++++++++++++++ .../OpenSamlLogoutRequestResolverTests.java | 115 ++++++++ .../OpenSamlLogoutResponseResolverTests.java | 125 ++++++++ .../logout/Saml2LogoutRequestFilterTests.java | 155 ++++++++++ .../Saml2LogoutResponseFilterTests.java | 153 ++++++++++ ...rtyInitiatedLogoutSuccessHandlerTests.java | 107 +++++++ 49 files changed, 5738 insertions(+), 34 deletions(-) create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlVerificationUtils.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidator.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidatorParameters.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidator.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidatorParameters.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutValidatorResult.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlSigningUtils.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandler.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java create mode 100644 saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java create mode 100644 saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java create mode 100644 saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolverTests.java create mode 100644 saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolverTests.java create mode 100644 saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java create mode 100644 saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java create mode 100644 saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolverTests.java create mode 100644 saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolverTests.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlSigningUtils.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests.java diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc index 33e7c5ad35a..9092346b2b7 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc @@ -1618,35 +1618,281 @@ filter.setRequestMatcher(AntPathRequestMatcher("/saml2/metadata", "GET")) [[servlet-saml2login-logout]] === Performing Single Logout -Spring Security does not yet support single logout. +Spring Security ships with support for RP- and AP-initiated SAML 2.0 Single Logout. -Generally speaking, though, you can achieve this by creating and registering a custom `LogoutSuccessHandler` and `RequestMatcher`: +Briefly, there are two use cases Spring Security supports: + +* **RP-Initiated** - Your application has an endpoint that, when POSTed to, will logout the user and send a `saml2:LogoutRequest` to the asserting party. +Thereafter, the asserting party will send back a `saml2:LogoutResponse` and allow your application to respond +* **AP-Initiated** - Your application has an endpoint that will receive a `saml2:LogoutRequest` from the asserting party. +Your application will complete its logout at that point and then send a `saml2:LogoutResponse` to the asserting party. + +[NOTE] +In the **AP-Initiated** scenario, any local redirection that your application would do post-logout is rendered moot. +Once your application sends a `saml2:LogoutResponse`, it no longer has control of the browser. + +=== Minimal Configuration for Single Logout + +To use Spring Security's SAML 2.0 Single Logout feature, you will need the following things: + +* First, the asserting party must support SAML 2.0 Single Logout +* Second, the asserting party should be configured to sign and POST `saml2:LogoutRequest` s and `saml2:LogoutResponse` s your application's `/logout/saml2/slo` endpoint +* Third, your application must have a PKCS#8 private key and X.509 certificate for signing `saml2:LogoutRequest` s and `saml2:LogoutResponse` s + +==== RP-Initiated Single Logout + +Given those, then for RP-initiated Single Logout, you can begin from the initial minimal example and add the following configuration: + +[source,java] +---- +@Value("${private.key}") RSAPrivateKey key; +@Value("${public.certificate}") X509Certificate certificate; + +@Bean +RelyingPartyRegistrationRepository registrations() { + RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations + .fromMetadataLocation("https://ap.example.org/metadata") + .registrationId("id") + .singleLogoutServiceLocation("{baseUrl}/logout/saml2/slo") + .signingX509Credentials((signing) -> signing.add(Saml2X509Credential.signing(key, certificate))) <1> + .build(); + return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistration); +} + +@Bean +SecurityFilterChain web(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception { + RelyingPartyRegistrationResolver registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrations); + LogoutHandler logoutResponseHandler = logoutResponseHandler(registrationResolver); + LogoutSuccessHandler logoutRequestSuccessHandler = logoutRequestSuccessHandler(registrationResolver); + + http + .authorizeRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .saml2Login(withDefaults()) + .logout((logout) -> logout + .logoutUrl("/saml2/logout") + .logoutSuccessHandler(successHandler)) + .addFilterBefore(new Saml2LogoutResponseFilter(logoutHandler), CsrfFilter.class); + + return http.build(); +} + +private LogoutSuccessHandler logoutRequestSuccessHandler(RelyingPartyRegistrationResolver registrationResolver) { <2> + OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver(registrationResolver); + return new Saml2LogoutRequestSuccessHandler(logoutRequestResolver); +} + +private LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { <3> + return new OpenSamlLogoutResponseHandler(relyingPartyRegistrationResolver); +} +---- +<1> - First, add your signing key to the `RelyingPartyRegistration` instance or to <> +<2> - Second, supply a `LogoutSuccessHandler` for initiating Single Logout, sending a `saml2:LogoutRequest` to the asserting party +<3> - Third, supply the `LogoutHandler` s needed to handle the `saml2:LogoutResponse` s sent from the asserting party. + +==== Runtime Expectations for RP-Initiated + +Given the above configuration any logged in user can send a `POST /logout` to your application to perform RP-initiated SLO. +Your application will then do the following: + +1. Logout the user and invalidate the session +2. Use a `Saml2LogoutRequestResolver` to create, sign, and serialize a `` based on the <> associated with the currently logged-in user. +3. Send a redirect or post to the asserting party based on the <> +4. Deserialize, verify, and process the `` sent by the asserting party +5. Redirect to any configured successful logout endpoint + +[TIP] +If your asserting party does not send `` s when logout is complete, the asserting party can still send a `POST /saml2/logout` and then there is no need to configure the `Saml2LogoutResponseHandler`. + +==== AP-Initiated Single Logout + +Instead of RP-initiated Single Logout, you can again begin from the initial minimal example and add the following configuration to achieve AP-initiated Single Logout: + +[source,java] +---- +@Value("${private.key}") RSAPrivateKey key; +@Value("${public.certificate}") X509Certificate certificate; + +@Bean +RelyingPartyRegistrationRepository registrations() { + RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations + .fromMetadataLocation("https://ap.example.org/metadata") + .registrationId("id") + .signingX509Credentials((signing) -> signing.add(Saml2X509Credential.signing(key, certificate))) <1> + .build(); + return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistration); +} + +@Bean +SecurityFilterChain web(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception { + RelyingPartyRegistrationResolver registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrations); + LogoutHandler logoutRequestHandler = logoutRequestHandler(registrationResolver); + LogoutSuccessHandler logoutResponseSuccessHandler = logoutResponseSuccessHandler(registrationResolver); + + http + .authorizeRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .saml2Login(withDefaults()) + .addFilterBefore(new Saml2LogoutRequestFilter(logoutResponseSuccessHandler, logoutRequestHandler), CsrfFilter.class); + + return http.build(); +} + +private LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { <2> + return new CompositeLogoutHandler( + new OpenSamlLogoutRequestHandler(relyingPartyRegistrationResolver), + new SecurityContextLogoutHandler(), + new LogoutSuccessEventPublishingLogoutHandler()); +} + +private LogoutSuccessHandler logoutSuccessHandler(RelyingPartyRegistrationResolver registrationResolver) { <3> + OpenSaml4LogoutResponseResolver logoutResponseResolver = new OpenSaml4LogoutResponseResolver(registrationResolver); + return new Saml2LogoutResponseSuccessHandler(logoutResponseResolver); +} +---- +<1> - First, add your signing key to the `RelyingPartyRegistration` instance or to <> +<2> - Second, supply the `LogoutHandler` needed to handle the `saml2:LogoutRequest` s sent from the asserting party. +<3> - Third, supply a `LogoutSuccessHandler` for completing Single Logout, sending a `saml2:LogoutResponse` to the asserting party + +==== Runtime Expectations for AP-Initiated + +Given the above configuration, an asserting party can send a `POST /logout/saml2` to your application that includes a `` +Also, your application can participate in an AP-initated logout when the asserting party sends a `` to `/logout/saml2/slo`: + +1. Use a `Saml2LogoutRequestHandler` to deserialize, verify, and process the `` sent by the asserting party +2. Logout the user and invalidate the session +3. Create, sign, and serialize a `` based on the <> associated with the just logged-out user +4. Send a redirect or post to the asserting party based on the <> + +[TIP] +If your asserting party does not expect you do send a `` s when logout is complete, you may not need to configure a `LogoutSuccessHandler` + +[NOTE] +In the event that you need to support both logout flows, you can combine the above to configurations. + +=== Configuring Logout Endpoints + +There are three default endpoints that Spring Security's SAML 2.0 Single Logout support exposes: +* `/logout` - the endpoint for initiating single logout with an asserting party +* `/logout/saml2/slo` - the endpoint for receiving logout requests or responses from an asserting party + +Because the user is already logged in, the `registrationId` is already known. +For this reason, `+{registrationId}+` is not part of these URLs by default. + +These URLs are customizable in the DSL. + +For example, if you are migrating your existing relying party over to Spring Security, your asserting party may already be pointing to `GET /SLOService.saml2`. +To reduce changes in configuration for the asserting party, you can configure the filter in the DSL like so: ==== .Java [source,java,role="primary"] ---- +Saml2LogoutResponseFilter filter = new Saml2LogoutResponseFilter(logoutHandler); +filter.setLogoutRequestMatcher(new AntPathRequestMatcher("/SLOService.saml2", "GET")); http // ... - .logout(logout -> logout - .logoutSuccessHandler(myCustomSuccessHandler()) - .logoutRequestMatcher(myRequestMatcher()) - ) + .addFilterBefore(filter, CsrfFilter.class); ---- -.Kotlin -[source,kotlin,role="secondary"] +=== Customizing `` Resolution + +It's common to need to set other values in the `` than the defaults that Spring Security provides. + +By default, Spring Security will issue a `` and supply: + +* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyDetails#getSingleLogoutServiceLocation` +* The `ID` attribute - a GUID +* The `` element - from `RelyingPartyRegistration#getEntityId` +* The `` element - from `Authentication#getName` + +To add other values, you can use delegation, like so: + +[source,java] ---- -http { - logout { - // ... - logoutSuccessHandler = myCustomSuccessHandler() - logoutRequestMatcher = myRequestMatcher() +OpenSamlLogoutRequestResolver delegate = new OpenSamlLogoutRequestResolver(registrationResolver); +return (request, response, authentication) -> { + OpenSamlLogoutRequestBuilder builder = delegate.resolveLogoutRequest(request, response, authentication); <1> + builder.name(((Saml2AuthenticatedPrincipal) authentication.getPrincipal()).getFirstAttribute("CustomAttribute")); <2> + builder.logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(DateTime.now())); + return builder.logoutRequest(); <3> +}; +---- +<1> - Spring Security applies default values to a `` +<2> - Your application specifies customizations +<3> - You complete the invocation by calling `request()` + +[NOTE] +Support for OpenSAML 4 is coming. +In anticipation of that, `OpenSamlLogoutRequestResolver` does not add an `IssueInstant`. +Once OpenSAML 4 support is added, the default will be able to appropriate negotiate that datatype change, meaning you will no longer have to set it. + +=== Customizing `` Resolution + +It's common to need to set other values in the `` than the defaults that Spring Security provides. + +By default, Spring Security will issue a `` and supply: + +* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyDetails#getSingleLogoutServiceResponseLocation` +* The `ID` attribute - a GUID +* The `` element - from `RelyingPartyRegistration#getEntityId` +* The `` element - `SUCCESS` + +To add other values, you can use delegation, like so: + +[source,java] +---- +OpenSamlLogoutResponseResolver delegate = new OpenSamlLogoutResponseResolver(registrationResolver); +return (request, response, authentication) -> { + OpenSamlLogoutResponseBuilder builder = delegate.resolveLogoutResponse(request, response, authentication); <1> + if (checkOtherPrevailingConditions()) { + builder.status(StatusCode.PARTIAL_LOGOUT); <2> } + builder.logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(DateTime.now())); + return builder.logoutResponse(); <3> +}; +---- +<1> - Spring Security applies default values to a `` +<2> - Your application specifies customizations +<3> - You complete the invocation by calling `response()` + +[NOTE] +Support for OpenSAML 4 is coming. +In anticipation of that, `OpenSamlLogoutResponseResolver` does not add an `IssueInstant`. +Once OpenSAML 4 support is added, the default will be able to appropriate negotiate that datatype change, meaning you will no longer have to set it. + +=== Customizing `` Validation + +To customize validation, you can implement your own `LogoutHandler`. +At this point, the validation is minimal, so you may be able to first delegate to the default `LogoutHandler` like so: + +[source,java] +---- +LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { + OpenSamlLogoutRequestHandler delegate = new OpenSamlLogoutRequestHandler(registrationResolver); + return (request, response, authentication) -> { + delegate.logout(request, response, authentication); // verify signature, issuer, destination, and principal name + LogoutRequest logoutRequest = // ... parse using OpenSAML + // perform custom validation + } } ---- -==== -The success handler will send logout requests to the asserting party. +=== Customizing `` Validation -The request matcher will detect logout requests from the asserting party. +To customize validation, you can implement your own `LogoutHandler`. +At this point, the validation is minimal, so you may be able to first delegate to the default `LogoutHandler` like so: + +[source,java] +---- +LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { + OpenSamlLogoutResponseHandler delegate = new OpenSamlLogoutResponseHandler(registrationResolver); + return (request, response, authentication) -> { + delegate.logout(request, response, authentication); // verify signature, issuer, destination, and status + LogoutResponse logoutResponse = // ... parse using OpenSAML + // perform custom validation + } +} +---- diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java index c5cfda1a475..b7f4d9c7994 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java @@ -37,6 +37,13 @@ public interface Saml2ErrorCodes { */ String MALFORMED_RESPONSE_DATA = "malformed_response_data"; + /** + * Request is invalid in a general way. + * + * @since 5.6 + */ + String INVALID_REQUEST = "invalid_request"; + /** * Response is invalid in a general way. * diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java new file mode 100644 index 00000000000..69df68246a2 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidator.java @@ -0,0 +1,175 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.function.Consumer; + +import net.shibboleth.utilities.java.support.xml.ParserPool; +import org.opensaml.core.config.ConfigurationService; +import org.opensaml.core.xml.config.XMLObjectProviderRegistry; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.impl.LogoutRequestUnmarshaller; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.OpenSamlInitializationService; +import org.springframework.security.saml2.core.Saml2Error; +import org.springframework.security.saml2.core.Saml2ErrorCodes; +import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlVerificationUtils.VerifierPartial; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; + +/** + * A {@link Saml2LogoutRequestValidator} that authenticates a SAML 2.0 Logout Requests + * received from a SAML 2.0 Asserting Party using OpenSAML. + * + * @author Josh Cummings + * @since 5.6 + */ +public final class OpenSamlLogoutRequestValidator implements Saml2LogoutRequestValidator { + + static { + OpenSamlInitializationService.initialize(); + } + + private final ParserPool parserPool; + + private final LogoutRequestUnmarshaller unmarshaller; + + /** + * Constructs a {@link OpenSamlLogoutRequestValidator} + */ + public OpenSamlLogoutRequestValidator() { + XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class); + this.parserPool = registry.getParserPool(); + this.unmarshaller = (LogoutRequestUnmarshaller) XMLObjectProviderRegistrySupport.getUnmarshallerFactory() + .getUnmarshaller(LogoutRequest.DEFAULT_ELEMENT_NAME); + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutValidatorResult validate(Saml2LogoutRequestValidatorParameters parameters) { + Saml2LogoutRequest request = parameters.getLogoutRequest(); + RelyingPartyRegistration registration = parameters.getRelyingPartyRegistration(); + Authentication authentication = parameters.getAuthentication(); + byte[] b = Saml2Utils.samlDecode(request.getSamlRequest()); + LogoutRequest logoutRequest = parse(inflateIfRequired(request, b)); + return Saml2LogoutValidatorResult.withErrors().errors(verifySignature(request, logoutRequest, registration)) + .errors(validateRequest(logoutRequest, registration, authentication)).build(); + } + + private String inflateIfRequired(Saml2LogoutRequest request, byte[] b) { + if (request.getBinding() == Saml2MessageBinding.REDIRECT) { + return Saml2Utils.samlInflate(b); + } + return new String(b, StandardCharsets.UTF_8); + } + + private LogoutRequest parse(String request) throws Saml2Exception { + try { + Document document = this.parserPool + .parse(new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8))); + Element element = document.getDocumentElement(); + return (LogoutRequest) this.unmarshaller.unmarshall(element); + } + catch (Exception ex) { + throw new Saml2Exception("Failed to deserialize LogoutRequest", ex); + } + } + + private Consumer> verifySignature(Saml2LogoutRequest request, LogoutRequest logoutRequest, + RelyingPartyRegistration registration) { + return (errors) -> { + VerifierPartial partial = OpenSamlVerificationUtils.verifySignature(logoutRequest, registration); + if (logoutRequest.isSigned()) { + errors.addAll(partial.post(logoutRequest.getSignature())); + } + else { + errors.addAll(partial.redirect(request)); + } + }; + } + + private Consumer> validateRequest(LogoutRequest request, + RelyingPartyRegistration registration, Authentication authentication) { + return (errors) -> { + validateIssuer(request, registration).accept(errors); + validateDestination(request, registration).accept(errors); + validateName(request, authentication).accept(errors); + }; + } + + private Consumer> validateIssuer(LogoutRequest request, + RelyingPartyRegistration registration) { + return (errors) -> { + if (request.getIssuer() == null) { + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse")); + return; + } + String issuer = request.getIssuer().getValue(); + if (!issuer.equals(registration.getAssertingPartyDetails().getEntityId())) { + errors.add( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); + } + }; + } + + private Consumer> validateDestination(LogoutRequest request, + RelyingPartyRegistration registration) { + return (errors) -> { + if (request.getDestination() == null) { + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to find destination in LogoutResponse")); + 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 Consumer> validateName(LogoutRequest request, Authentication authentication) { + return (errors) -> { + if (authentication == null) { + return; + } + NameID nameId = request.getNameID(); + if (nameId == null) { + errors.add( + new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, "Failed to find subject in LogoutRequest")); + return; + } + String name = nameId.getValue(); + if (!name.equals(authentication.getName())) { + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, + "Failed to match subject in LogoutRequest with currently logged in user")); + } + }; + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java new file mode 100644 index 00000000000..5dd903a0666 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidator.java @@ -0,0 +1,187 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.function.Consumer; + +import net.shibboleth.utilities.java.support.xml.ParserPool; +import org.opensaml.core.config.ConfigurationService; +import org.opensaml.core.xml.config.XMLObjectProviderRegistry; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.saml.saml2.core.LogoutResponse; +import org.opensaml.saml.saml2.core.StatusCode; +import org.opensaml.saml.saml2.core.impl.LogoutResponseUnmarshaller; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.OpenSamlInitializationService; +import org.springframework.security.saml2.core.Saml2Error; +import org.springframework.security.saml2.core.Saml2ErrorCodes; +import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlVerificationUtils.VerifierPartial; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; + +/** + * A {@link Saml2LogoutResponseValidator} that authenticates a SAML 2.0 Logout Responses + * received from a SAML 2.0 Asserting Party using OpenSAML. + * + * @author Josh Cummings + * @since 5.6 + */ +public class OpenSamlLogoutResponseValidator implements Saml2LogoutResponseValidator { + + static { + OpenSamlInitializationService.initialize(); + } + + private final ParserPool parserPool; + + private final LogoutResponseUnmarshaller unmarshaller; + + /** + * Constructs a {@link OpenSamlLogoutRequestValidator} + */ + public OpenSamlLogoutResponseValidator() { + XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class); + this.parserPool = registry.getParserPool(); + this.unmarshaller = (LogoutResponseUnmarshaller) XMLObjectProviderRegistrySupport.getUnmarshallerFactory() + .getUnmarshaller(LogoutResponse.DEFAULT_ELEMENT_NAME); + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutValidatorResult validate(Saml2LogoutResponseValidatorParameters parameters) { + Saml2LogoutResponse response = parameters.getLogoutResponse(); + Saml2LogoutRequest request = parameters.getLogoutRequest(); + RelyingPartyRegistration registration = parameters.getRelyingPartyRegistration(); + byte[] b = Saml2Utils.samlDecode(response.getSamlResponse()); + LogoutResponse logoutResponse = parse(inflateIfRequired(response, b)); + return Saml2LogoutValidatorResult.withErrors().errors(verifySignature(response, logoutResponse, registration)) + .errors(validateRequest(logoutResponse, registration)) + .errors(validateLogoutRequest(logoutResponse, request.getId())).build(); + } + + private String inflateIfRequired(Saml2LogoutResponse response, byte[] b) { + if (response.getBinding() == Saml2MessageBinding.REDIRECT) { + return Saml2Utils.samlInflate(b); + } + return new String(b, StandardCharsets.UTF_8); + } + + private LogoutResponse parse(String response) throws Saml2Exception { + try { + Document document = this.parserPool + .parse(new ByteArrayInputStream(response.getBytes(StandardCharsets.UTF_8))); + Element element = document.getDocumentElement(); + return (LogoutResponse) this.unmarshaller.unmarshall(element); + } + catch (Exception ex) { + throw new Saml2Exception("Failed to deserialize LogoutResponse", ex); + } + } + + private Consumer> verifySignature(Saml2LogoutResponse response, + LogoutResponse logoutResponse, RelyingPartyRegistration registration) { + return (errors) -> { + VerifierPartial partial = OpenSamlVerificationUtils.verifySignature(logoutResponse, registration); + if (logoutResponse.isSigned()) { + errors.addAll(partial.post(logoutResponse.getSignature())); + } + else { + errors.addAll(partial.redirect(response)); + } + }; + } + + private Consumer> validateRequest(LogoutResponse response, + RelyingPartyRegistration registration) { + return (errors) -> { + validateIssuer(response, registration).accept(errors); + validateDestination(response, registration).accept(errors); + validateStatus(response).accept(errors); + }; + } + + private Consumer> 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.getAssertingPartyDetails().getEntityId())) { + errors.add( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); + } + }; + } + + private Consumer> 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 Consumer> 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 Consumer> 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")); + }; + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlVerificationUtils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlVerificationUtils.java new file mode 100644 index 00000000000..ae3e8cb6a4b --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlVerificationUtils.java @@ -0,0 +1,247 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import net.shibboleth.utilities.java.support.resolver.CriteriaSet; +import org.opensaml.core.criterion.EntityIdCriterion; +import org.opensaml.saml.common.xml.SAMLConstants; +import org.opensaml.saml.criterion.ProtocolCriterion; +import org.opensaml.saml.metadata.criteria.role.impl.EvaluableProtocolRoleDescriptorCriterion; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.RequestAbstractType; +import org.opensaml.saml.saml2.core.StatusResponseType; +import org.opensaml.saml.security.impl.SAMLSignatureProfileValidator; +import org.opensaml.security.credential.Credential; +import org.opensaml.security.credential.CredentialResolver; +import org.opensaml.security.credential.UsageType; +import org.opensaml.security.credential.criteria.impl.EvaluableEntityIDCredentialCriterion; +import org.opensaml.security.credential.criteria.impl.EvaluableUsageCredentialCriterion; +import org.opensaml.security.credential.impl.CollectionCredentialResolver; +import org.opensaml.security.criteria.UsageCriterion; +import org.opensaml.security.x509.BasicX509Credential; +import org.opensaml.xmlsec.config.impl.DefaultSecurityConfigurationBootstrap; +import org.opensaml.xmlsec.signature.Signature; +import org.opensaml.xmlsec.signature.support.SignatureTrustEngine; +import org.opensaml.xmlsec.signature.support.impl.ExplicitKeySignatureTrustEngine; + +import org.springframework.security.saml2.core.Saml2Error; +import org.springframework.security.saml2.core.Saml2ErrorCodes; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.web.util.UriUtils; + +/** + * Utility methods for verifying SAML component signatures with OpenSAML + * + * For internal use only. + * + * @author Josh Cummings + */ + +final class OpenSamlVerificationUtils { + + static VerifierPartial verifySignature(StatusResponseType object, RelyingPartyRegistration registration) { + return new VerifierPartial(object, registration); + } + + static VerifierPartial verifySignature(RequestAbstractType object, RelyingPartyRegistration registration) { + return new VerifierPartial(object, registration); + } + + static class VerifierPartial { + + private final String id; + + private final CriteriaSet criteria; + + private final SignatureTrustEngine trustEngine; + + VerifierPartial(StatusResponseType object, RelyingPartyRegistration registration) { + this.id = object.getID(); + this.criteria = verificationCriteria(object.getIssuer()); + this.trustEngine = trustEngine(registration); + } + + VerifierPartial(RequestAbstractType object, RelyingPartyRegistration registration) { + this.id = object.getID(); + this.criteria = verificationCriteria(object.getIssuer()); + this.trustEngine = trustEngine(registration); + } + + Collection redirect(Saml2LogoutRequest request) { + return redirect(new RedirectSignature(request)); + } + + Collection redirect(Saml2LogoutResponse response) { + return redirect(new RedirectSignature(response)); + } + + Collection redirect(RedirectSignature signature) { + if (signature.getAlgorithm() == null) { + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + "Missing signature algorithm for object [" + this.id + "]")); + } + if (!signature.hasSignature()) { + return Collections.singletonList(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + "Missing signature for object [" + this.id + "]")); + } + Collection errors = new ArrayList<>(); + String algorithmUri = signature.getAlgorithm(); + try { + if (!this.trustEngine.validate(signature.getSignature(), signature.getContent(), algorithmUri, + this.criteria, null)) { + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + "Invalid signature for object [" + this.id + "]")); + } + } + catch (Exception ex) { + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + "Invalid signature for object [" + this.id + "]: ")); + } + return errors; + } + + Collection post(Signature signature) { + Collection errors = new ArrayList<>(); + SAMLSignatureProfileValidator profileValidator = new SAMLSignatureProfileValidator(); + try { + profileValidator.validate(signature); + } + catch (Exception ex) { + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + "Invalid signature for object [" + this.id + "]: ")); + } + + try { + if (!this.trustEngine.validate(signature, this.criteria)) { + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + "Invalid signature for object [" + this.id + "]")); + } + } + catch (Exception ex) { + errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + "Invalid signature for object [" + this.id + "]: ")); + } + + return errors; + } + + private CriteriaSet verificationCriteria(Issuer issuer) { + CriteriaSet criteria = new CriteriaSet(); + criteria.add(new EvaluableEntityIDCredentialCriterion(new EntityIdCriterion(issuer.getValue()))); + criteria.add(new EvaluableProtocolRoleDescriptorCriterion(new ProtocolCriterion(SAMLConstants.SAML20P_NS))); + criteria.add(new EvaluableUsageCredentialCriterion(new UsageCriterion(UsageType.SIGNING))); + return criteria; + } + + private SignatureTrustEngine trustEngine(RelyingPartyRegistration registration) { + Set credentials = new HashSet<>(); + Collection keys = registration.getAssertingPartyDetails() + .getVerificationX509Credentials(); + for (Saml2X509Credential key : keys) { + BasicX509Credential cred = new BasicX509Credential(key.getCertificate()); + cred.setUsageType(UsageType.SIGNING); + cred.setEntityId(registration.getAssertingPartyDetails().getEntityId()); + credentials.add(cred); + } + CredentialResolver credentialsResolver = new CollectionCredentialResolver(credentials); + return new ExplicitKeySignatureTrustEngine(credentialsResolver, + DefaultSecurityConfigurationBootstrap.buildBasicInlineKeyInfoCredentialResolver()); + } + + private static class RedirectSignature { + + private final String algorithm; + + private final byte[] signature; + + private final byte[] content; + + RedirectSignature(Saml2LogoutRequest request) { + this.algorithm = request.getParameter("SigAlg"); + if (request.getParameter("Signature") != null) { + this.signature = Saml2Utils.samlDecode(request.getParameter("Signature")); + } + else { + this.signature = null; + } + this.content = content(request.getSamlRequest(), "SAMLRequest", request.getRelayState(), + request.getParameter("SigAlg")); + } + + RedirectSignature(Saml2LogoutResponse response) { + this.algorithm = response.getParameter("SigAlg"); + if (response.getParameter("Signature") != null) { + this.signature = Saml2Utils.samlDecode(response.getParameter("Signature")); + } + else { + this.signature = null; + } + this.content = content(response.getSamlResponse(), "SAMLResponse", response.getRelayState(), + response.getParameter("SigAlg")); + } + + static byte[] content(String samlObject, String objectParameterName, String relayState, String algorithm) { + if (relayState != null) { + return String + .format("%s=%s&RelayState=%s&SigAlg=%s", objectParameterName, + UriUtils.encode(samlObject, StandardCharsets.ISO_8859_1), + UriUtils.encode(relayState, StandardCharsets.ISO_8859_1), + UriUtils.encode(algorithm, StandardCharsets.ISO_8859_1)) + .getBytes(StandardCharsets.UTF_8); + } + else { + return String + .format("%s=%s&SigAlg=%s", objectParameterName, + UriUtils.encode(samlObject, StandardCharsets.ISO_8859_1), + UriUtils.encode(algorithm, StandardCharsets.ISO_8859_1)) + .getBytes(StandardCharsets.UTF_8); + } + } + + byte[] getContent() { + return this.content; + } + + String getAlgorithm() { + return this.algorithm; + } + + byte[] getSignature() { + return this.signature; + } + + boolean hasSignature() { + return this.signature != null; + } + + } + + } + + private OpenSamlVerificationUtils() { + + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java new file mode 100644 index 00000000000..17b934eba23 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java @@ -0,0 +1,248 @@ +/* + * Copyright 2002-2020 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.authentication.logout; + +import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestResolver; + +/** + * A class that represents a signed and serialized SAML 2.0 Logout Request + * + * @author Josh Cummings + * @since 5.6 + */ +public final class Saml2LogoutRequest implements Serializable { + + private final String location; + + private final Saml2MessageBinding binding; + + private final Map parameters; + + private final String id; + + private final String relyingPartyRegistrationId; + + private Saml2LogoutRequest(String location, Saml2MessageBinding binding, Map parameters, String id, + String relyingPartyRegistrationId) { + this.location = location; + this.binding = binding; + this.parameters = Collections.unmodifiableMap(new HashMap<>(parameters)); + this.id = id; + this.relyingPartyRegistrationId = relyingPartyRegistrationId; + } + + /** + * The unique identifier for this Logout Request + * @return the Logout Request identifier + */ + public String getId() { + return this.id; + } + + /** + * Get the location of the asserting party's SingleLogoutService + * @return the SingleLogoutService location + */ + public String getLocation() { + return this.location; + } + + /** + * Get the binding for the asserting party's SingleLogoutService + * @return the SingleLogoutService binding + */ + public Saml2MessageBinding getBinding() { + return this.binding; + } + + /** + * Get the signed and serialized <saml2:LogoutRequest> payload + * @return the signed and serialized <saml2:LogoutRequest> payload + */ + public String getSamlRequest() { + return this.parameters.get("SAMLRequest"); + } + + /** + * The relay state associated with this Logout Request + * @return the relay state + */ + public String getRelayState() { + return this.parameters.get("RelayState"); + } + + /** + * Get the {@code name} parameters, a short-hand for + * getParameters().get(name) + * + * + * Useful when specifying additional query parameters for the Logout Request + * @param name the parameter's name + * @return the parameter's value + */ + public String getParameter(String name) { + return this.parameters.get(name); + } + + /** + * Get all parameters + * + * Useful when specifying additional query parameters for the Logout Request + * @return the Logout Request query parameters + */ + public Map getParameters() { + return this.parameters; + } + + /** + * The identifier for the {@link RelyingPartyRegistration} associated with this Logout + * Request + * @return the {@link RelyingPartyRegistration} id + */ + public String getRelyingPartyRegistrationId() { + return this.relyingPartyRegistrationId; + } + + /** + * Create a {@link Builder} instance from this {@link RelyingPartyRegistration} + * + * Specifically, this will pull the SingleLogoutService + * location and binding from the {@link RelyingPartyRegistration} + * @param registration the {@link RelyingPartyRegistration} to use + * @return the {@link Builder} for further configurations + */ + public static Builder withRelyingPartyRegistration(RelyingPartyRegistration registration) { + return new Builder(registration); + } + + public static final class Builder { + + private final RelyingPartyRegistration registration; + + private String location; + + private Saml2MessageBinding binding; + + private Map parameters = new HashMap<>(); + + private String id; + + private Builder(RelyingPartyRegistration registration) { + this.registration = registration; + this.location = registration.getAssertingPartyDetails().getSingleLogoutServiceLocation(); + this.binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(); + } + + /** + * Use this signed and serialized and Base64-encoded <saml2:LogoutRequest> + * + * Note that if using the Redirect binding, the value should be + * {@link java.util.zip.DeflaterOutputStream deflated} and then Base64-encoded. + * + * It should not be URL-encoded as this will be done when the request is sent + * @param samlRequest the <saml2:LogoutRequest> to use + * @return the {@link Builder} for further configurations + * @see Saml2LogoutRequestResolver + */ + public Builder samlRequest(String samlRequest) { + this.parameters.put("SAMLRequest", samlRequest); + return this; + } + + /** + * Use this SAML 2.0 Message Binding + * + * By default, the asserting party's configured binding is used + * @param binding the SAML 2.0 Message Binding to use + * @return the {@link Builder} for further configurations + */ + public Builder binding(Saml2MessageBinding binding) { + this.binding = binding; + return this; + } + + /** + * Use this location for the SAML 2.0 logout endpoint + * + * By default, the asserting party's endpoint is used + * @param location the SAML 2.0 location to use + * @return the {@link Builder} for further configurations + */ + public Builder location(String location) { + this.location = location; + return this; + } + + /** + * Use this value for the relay state when sending the Logout Request to the + * asserting party + * + * It should not be URL-encoded as this will be done when the request is sent + * @param relayState the relay state + * @return the {@link Builder} for further configurations + */ + public Builder relayState(String relayState) { + this.parameters.put("RelayState", relayState); + return this; + } + + /** + * This is the unique id used in the {@link #samlRequest} + * @param id the Logout Request id + * @return the {@link Builder} for further configurations + */ + public Builder id(String id) { + this.id = id; + return this; + } + + /** + * Use this {@link Consumer} to modify the set of query parameters + * + * No parameter should be URL-encoded as this will be done when the request is + * sent + * @param parametersConsumer the {@link Consumer} + * @return the {@link Builder} for further configurations + */ + public Builder parameters(Consumer> parametersConsumer) { + parametersConsumer.accept(this.parameters); + return this; + } + + /** + * Build the {@link Saml2LogoutRequest} + * @return a constructed {@link Saml2LogoutRequest} + */ + public Saml2LogoutRequest build() { + return new Saml2LogoutRequest(this.location, this.binding, this.parameters, this.id, + this.registration.getRegistrationId()); + } + + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidator.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidator.java new file mode 100644 index 00000000000..bbcde5443a5 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidator.java @@ -0,0 +1,38 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +/** + * Validates SAML 2.0 Logout Requests + * + * @author Josh Cummings + * @since 5.6 + */ +public interface Saml2LogoutRequestValidator { + + /** + * Authenticates the SAML 2.0 Logout Request received from the SAML 2.0 Asserting + * Party. + * + * By default, verifies the signature, validates the issuer, destination, and user + * identifier. + * @param parameters the {@link Saml2LogoutRequestValidatorParameters} needed + * @return the authentication result + */ + Saml2LogoutValidatorResult validate(Saml2LogoutRequestValidatorParameters parameters); + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidatorParameters.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidatorParameters.java new file mode 100644 index 00000000000..c839ca449ee --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequestValidatorParameters.java @@ -0,0 +1,73 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; + +/** + * A holder of the parameters needed to invoke {@link Saml2LogoutRequestValidator} + * + * @author Josh Cummings + * @since 5.6 + */ +public class Saml2LogoutRequestValidatorParameters { + + private final Saml2LogoutRequest request; + + private final RelyingPartyRegistration registration; + + private final Authentication authentication; + + /** + * Construct a {@link Saml2LogoutRequestValidatorParameters} + * @param request the SAML 2.0 Logout Request received from the asserting party + * @param registration the associated {@link RelyingPartyRegistration} + * @param authentication the current user + */ + public Saml2LogoutRequestValidatorParameters(Saml2LogoutRequest request, RelyingPartyRegistration registration, + Authentication authentication) { + this.request = request; + this.registration = registration; + this.authentication = authentication; + } + + /** + * The SAML 2.0 Logout Request sent by the asserting party + * @return the logout request + */ + public Saml2LogoutRequest getLogoutRequest() { + return this.request; + } + + /** + * The {@link RelyingPartyRegistration} representing this relying party + * @return the relying party + */ + public RelyingPartyRegistration getRelyingPartyRegistration() { + return this.registration; + } + + /** + * The current {@link Authentication} + * @return the authenticated user + */ + public Authentication getAuthentication() { + return this.authentication; + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java new file mode 100644 index 00000000000..2f212c9b9e7 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java @@ -0,0 +1,207 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseResolver; + +/** + * A class that represents a signed and serialized SAML 2.0 Logout Response + * + * @author Josh Cummings + * @since 5.6 + */ +public final class Saml2LogoutResponse { + + private final String location; + + private final Saml2MessageBinding binding; + + private final Map parameters; + + private Saml2LogoutResponse(String location, Saml2MessageBinding binding, Map parameters) { + this.location = location; + this.binding = binding; + this.parameters = Collections.unmodifiableMap(new HashMap<>(parameters)); + } + + /** + * Get the response location of the asserting party's SingleLogoutService + * @return the SingleLogoutService response location + */ + public String getResponseLocation() { + return this.location; + } + + /** + * Get the binding for the asserting party's SingleLogoutService + * @return the SingleLogoutService binding + */ + public Saml2MessageBinding getBinding() { + return this.binding; + } + + /** + * Get the signed and serialized <saml2:LogoutResponse> payload + * @return the signed and serialized <saml2:LogoutResponse> payload + */ + public String getSamlResponse() { + return this.parameters.get("SAMLResponse"); + } + + /** + * The relay state associated with this Logout Request + * @return the relay state + */ + public String getRelayState() { + return this.parameters.get("RelayState"); + } + + /** + * Get the {@code name} parameter, a short-hand for + * getParameters().get(name) + * + * + * Useful when specifying additional query parameters for the Logout Response + * @param name the parameter's name + * @return the parameter's value + */ + public String getParameter(String name) { + return this.parameters.get(name); + } + + /** + * Get all parameters + * + * Useful when specifying additional query parameters for the Logout Response + * @return the Logout Response query parameters + */ + public Map getParameters() { + return this.parameters; + } + + /** + * Create a {@link Builder} instance from this {@link RelyingPartyRegistration} + * + * Specifically, this will pull the SingleLogoutService + * response location and binding from the {@link RelyingPartyRegistration} + * @param registration the {@link RelyingPartyRegistration} to use + * @return the {@link Builder} for further configurations + */ + public static Builder withRelyingPartyRegistration(RelyingPartyRegistration registration) { + return new Builder(registration); + } + + public static final class Builder { + + private String location; + + private Saml2MessageBinding binding; + + private Map parameters = new HashMap<>(); + + private Builder(RelyingPartyRegistration registration) { + this.location = registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation(); + this.binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(); + } + + /** + * Use this signed and serialized and Base64-encoded <saml2:LogoutResponse> + * + * Note that if using the Redirect binding, the value should be + * {@link java.util.zip.DeflaterOutputStream deflated} and then Base64-encoded. + * + * It should not be URL-encoded as this will be done when the response is sent + * @param samlResponse the <saml2:LogoutResponse> to use + * @return the {@link Builder} for further configurations + * @see Saml2LogoutResponseResolver + */ + public Builder samlResponse(String samlResponse) { + this.parameters.put("SAMLResponse", samlResponse); + return this; + } + + /** + * Use this SAML 2.0 Message Binding + * + * By default, the asserting party's configured binding is used + * @param binding the SAML 2.0 Message Binding to use + * @return the {@link Saml2LogoutRequest.Builder} for further configurations + */ + public Builder binding(Saml2MessageBinding binding) { + this.binding = binding; + return this; + } + + /** + * Use this location for the SAML 2.0 logout endpoint + * + * By default, the asserting party's endpoint is used + * @param location the SAML 2.0 location to use + * @return the {@link Saml2LogoutRequest.Builder} for further configurations + */ + public Builder location(String location) { + this.location = location; + return this; + } + + /** + * Use this value for the relay state when sending the Logout Request to the + * asserting party + * + * It should not be URL-encoded as this will be done when the response is sent + * @param relayState the relay state + * @return the {@link Builder} for further configurations + */ + public Builder relayState(String relayState) { + this.parameters.put("RelayState", relayState); + return this; + } + + /** + * Use this {@link Consumer} to modify the set of query parameters + * + * No parameter should be URL-encoded as this will be done when the response is + * sent, though any signature specified should be Base64-encoded + * @param parametersConsumer the {@link Consumer} + * @return the {@link Builder} for further configurations + */ + public Builder parameters(Consumer> parametersConsumer) { + parametersConsumer.accept(this.parameters); + return this; + } + + /** + * Build the {@link Saml2LogoutResponse} + * @return a constructed {@link Saml2LogoutResponse} + */ + public Saml2LogoutResponse build() { + return new Saml2LogoutResponse(this.location, this.binding, this.parameters); + } + + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidator.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidator.java new file mode 100644 index 00000000000..d7c6e59264b --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidator.java @@ -0,0 +1,38 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +/** + * Validates SAML 2.0 Logout Responses + * + * @author Josh Cummings + * @since 5.6 + */ +public interface Saml2LogoutResponseValidator { + + /** + * Authenticates the SAML 2.0 Logout Response received from the SAML 2.0 Asserting + * Party. + * + * By default, verifies the signature, validates the issuer, destination, and status. + * It also ensures that it aligns with the given logout request. + * @param parameters the {@link Saml2LogoutResponseValidatorParameters} needed + * @return the authentication result + */ + Saml2LogoutValidatorResult validate(Saml2LogoutResponseValidatorParameters parameters); + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidatorParameters.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidatorParameters.java new file mode 100644 index 00000000000..b052d2c5839 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponseValidatorParameters.java @@ -0,0 +1,72 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; + +/** + * A holder of the parameters needed to invoke {@link Saml2LogoutResponseValidator} + * + * @author Josh Cummings + * @since 5.6 + */ +public class Saml2LogoutResponseValidatorParameters { + + private final Saml2LogoutResponse response; + + private final Saml2LogoutRequest request; + + private final RelyingPartyRegistration registration; + + /** + * Construct a {@link Saml2LogoutRequestValidatorParameters} + * @param response the SAML 2.0 Logout Response received from the asserting party + * @param request the SAML 2.0 Logout Request send by this application + * @param registration the associated {@link RelyingPartyRegistration} + */ + public Saml2LogoutResponseValidatorParameters(Saml2LogoutResponse response, Saml2LogoutRequest request, + RelyingPartyRegistration registration) { + this.response = response; + this.request = request; + this.registration = registration; + } + + /** + * The SAML 2.0 Logout Response received from the asserting party + * @return the logout response + */ + public Saml2LogoutResponse getLogoutResponse() { + return this.response; + } + + /** + * The SAML 2.0 Logout Request sent by this application + * @return the logout request + */ + public Saml2LogoutRequest getLogoutRequest() { + return this.request; + } + + /** + * The {@link RelyingPartyRegistration} representing this relying party + * @return the relying party + */ + public RelyingPartyRegistration getRelyingPartyRegistration() { + return this.registration; + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutValidatorResult.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutValidatorResult.java new file mode 100644 index 00000000000..16434be6d6e --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutValidatorResult.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-2020 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.authentication.logout; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.function.Consumer; + +import org.springframework.security.saml2.core.Saml2Error; +import org.springframework.util.Assert; + +/** + * A result emitted from a SAML 2.0 Logout validation attempt + * + * @author Josh Cummings + * @since 5.6 + */ +public final class Saml2LogoutValidatorResult { + + static final Saml2LogoutValidatorResult NO_ERRORS = new Saml2LogoutValidatorResult(Collections.emptyList()); + + private final Collection errors; + + private Saml2LogoutValidatorResult(Collection errors) { + Assert.notNull(errors, "errors cannot be null"); + this.errors = new ArrayList<>(errors); + } + + /** + * Say whether this result indicates success + * @return whether this result has errors + */ + public boolean hasErrors() { + return !this.errors.isEmpty(); + } + + /** + * Return error details regarding the validation attempt + * @return the collection of results in this result, if any; returns an empty list + * otherwise + */ + public Collection getErrors() { + return Collections.unmodifiableCollection(this.errors); + } + + /** + * Construct a successful {@link Saml2LogoutValidatorResult} + * @return an {@link Saml2LogoutValidatorResult} with no errors + */ + public static Saml2LogoutValidatorResult success() { + return NO_ERRORS; + } + + /** + * Construct a {@link Saml2LogoutValidatorResult.Builder}, starting with the given + * {@code errors}. + * + * Note that a result with no errors is considered a success. + * @param errors + * @return + */ + public static Saml2LogoutValidatorResult.Builder withErrors(Saml2Error... errors) { + return new Builder(errors); + } + + public static final class Builder { + + private final Collection errors; + + private Builder(Saml2Error... errors) { + this(Arrays.asList(errors)); + } + + private Builder(Collection errors) { + Assert.noNullElements(errors, "errors cannot have null elements"); + this.errors = new ArrayList<>(errors); + } + + public Builder errors(Consumer> errorsConsumer) { + errorsConsumer.accept(this.errors); + return this; + } + + public Saml2LogoutValidatorResult build() { + return new Saml2LogoutValidatorResult(this.errors); + } + + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java new file mode 100644 index 00000000000..0190a85dfb9 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2Utils.java @@ -0,0 +1,76 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterOutputStream; + +import org.springframework.security.saml2.Saml2Exception; + +/** + * Utility methods for working with serialized SAML messages. + * + * For internal use only. + * + * @author Josh Cummings + */ +final class Saml2Utils { + + private Saml2Utils() { + } + + static String samlEncode(byte[] b) { + return Base64.getEncoder().encodeToString(b); + } + + static byte[] samlDecode(String s) { + return Base64.getDecoder().decode(s); + } + + static byte[] samlDeflate(String s) { + try { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + DeflaterOutputStream deflater = new DeflaterOutputStream(b, new Deflater(Deflater.DEFLATED, true)); + deflater.write(s.getBytes(StandardCharsets.UTF_8)); + deflater.finish(); + return b.toByteArray(); + } + catch (IOException ex) { + throw new Saml2Exception("Unable to deflate string", ex); + } + } + + static String samlInflate(byte[] b) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + iout.write(b); + iout.finish(); + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + catch (IOException ex) { + throw new Saml2Exception("Unable to inflate string", ex); + } + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java index edcd9c35c6b..1f0d5c19af1 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java @@ -32,6 +32,7 @@ import org.opensaml.saml.saml2.metadata.EntityDescriptor; import org.opensaml.saml.saml2.metadata.KeyDescriptor; import org.opensaml.saml.saml2.metadata.SPSSODescriptor; +import org.opensaml.saml.saml2.metadata.SingleLogoutService; import org.opensaml.saml.saml2.metadata.impl.EntityDescriptorMarshaller; import org.opensaml.security.credential.UsageType; import org.opensaml.xmlsec.signature.KeyInfo; @@ -85,6 +86,7 @@ private SPSSODescriptor buildSpSsoDescriptor(RelyingPartyRegistration registrati spSsoDescriptor.getKeyDescriptors() .addAll(buildKeys(registration.getDecryptionX509Credentials(), UsageType.ENCRYPTION)); spSsoDescriptor.getAssertionConsumerServices().add(buildAssertionConsumerService(registration)); + spSsoDescriptor.getSingleLogoutServices().add(buildSingleLogoutService(registration)); return spSsoDescriptor; } @@ -123,6 +125,14 @@ private AssertionConsumerService buildAssertionConsumerService(RelyingPartyRegis return assertionConsumerService; } + private SingleLogoutService buildSingleLogoutService(RelyingPartyRegistration registration) { + SingleLogoutService singleLogoutService = build(SingleLogoutService.DEFAULT_ELEMENT_NAME); + singleLogoutService.setLocation(registration.getSingleLogoutServiceLocation()); + singleLogoutService.setResponseLocation(registration.getSingleLogoutServiceResponseLocation()); + singleLogoutService.setBinding(registration.getSingleLogoutServiceBinding().getUrn()); + return singleLogoutService; + } + @SuppressWarnings("unchecked") private T build(QName elementName) { XMLObjectBuilder builder = XMLObjectProviderRegistrySupport.getBuilderFactory().getBuilder(elementName); diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java index c7f04d90f4a..1b0eb0e35a9 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java @@ -34,6 +34,7 @@ import org.opensaml.saml.saml2.metadata.Extensions; import org.opensaml.saml.saml2.metadata.IDPSSODescriptor; import org.opensaml.saml.saml2.metadata.KeyDescriptor; +import org.opensaml.saml.saml2.metadata.SingleLogoutService; import org.opensaml.saml.saml2.metadata.SingleSignOnService; import org.opensaml.security.credential.UsageType; import org.opensaml.xmlsec.keyinfo.KeyInfoSupport; @@ -105,6 +106,10 @@ RelyingPartyRegistration.Builder convert(InputStream inputStream) { builder.assertingPartyDetails( (party) -> party.signingAlgorithms((algorithms) -> algorithms.add(method.getAlgorithm()))); } + if (idpssoDescriptor.getSingleSignOnServices().isEmpty()) { + throw new Saml2Exception( + "Metadata response is missing a SingleSignOnService, necessary for sending AuthnRequests"); + } for (SingleSignOnService singleSignOnService : idpssoDescriptor.getSingleSignOnServices()) { Saml2MessageBinding binding; if (singleSignOnService.getBinding().equals(Saml2MessageBinding.POST.getUrn())) { @@ -119,10 +124,27 @@ else if (singleSignOnService.getBinding().equals(Saml2MessageBinding.REDIRECT.ge builder.assertingPartyDetails( (party) -> party.singleSignOnServiceLocation(singleSignOnService.getLocation()) .singleSignOnServiceBinding(binding)); - return builder; + break; + } + for (SingleLogoutService singleLogoutService : idpssoDescriptor.getSingleLogoutServices()) { + Saml2MessageBinding binding; + if (singleLogoutService.getBinding().equals(Saml2MessageBinding.POST.getUrn())) { + binding = Saml2MessageBinding.POST; + } + else if (singleLogoutService.getBinding().equals(Saml2MessageBinding.REDIRECT.getUrn())) { + binding = Saml2MessageBinding.REDIRECT; + } + else { + continue; + } + String responseLocation = (singleLogoutService.getResponseLocation() == null) + ? singleLogoutService.getLocation() : singleLogoutService.getResponseLocation(); + builder.assertingPartyDetails( + (party) -> party.singleLogoutServiceLocation(singleLogoutService.getLocation()) + .singleLogoutServiceResponseLocation(responseLocation).singleLogoutServiceBinding(binding)); + break; } - throw new Saml2Exception( - "Metadata response is missing a SingleSignOnService, necessary for sending AuthnRequests"); + return builder; } private List certificates(KeyDescriptor keyDescriptor) { diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java index 80db35d1317..d07a3664f8a 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java @@ -81,6 +81,12 @@ public final class RelyingPartyRegistration { private final Saml2MessageBinding assertionConsumerServiceBinding; + private final String singleLogoutServiceLocation; + + private final String singleLogoutServiceResponseLocation; + + private final Saml2MessageBinding singleLogoutServiceBinding; + private final ProviderDetails providerDetails; private final List credentials; @@ -90,7 +96,9 @@ public final class RelyingPartyRegistration { private final Collection signingX509Credentials; private RelyingPartyRegistration(String registrationId, String entityId, String assertionConsumerServiceLocation, - Saml2MessageBinding assertionConsumerServiceBinding, ProviderDetails providerDetails, + Saml2MessageBinding assertionConsumerServiceBinding, String singleLogoutServiceLocation, + String singleLogoutServiceResponseLocation, Saml2MessageBinding singleLogoutServiceBinding, + ProviderDetails providerDetails, Collection credentials, Collection decryptionX509Credentials, Collection signingX509Credentials) { @@ -118,6 +126,9 @@ private RelyingPartyRegistration(String registrationId, String entityId, String this.entityId = entityId; this.assertionConsumerServiceLocation = assertionConsumerServiceLocation; this.assertionConsumerServiceBinding = assertionConsumerServiceBinding; + this.singleLogoutServiceLocation = singleLogoutServiceLocation; + this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation; + this.singleLogoutServiceBinding = singleLogoutServiceBinding; this.providerDetails = providerDetails; this.credentials = Collections.unmodifiableList(new LinkedList<>(credentials)); this.decryptionX509Credentials = Collections.unmodifiableList(new LinkedList<>(decryptionX509Credentials)); @@ -177,6 +188,52 @@ public Saml2MessageBinding getAssertionConsumerServiceBinding() { return this.assertionConsumerServiceBinding; } + /** + * Get the SingleLogoutService + * Binding + * + * + *

+ * Equivalent to the value found in <SingleLogoutService Binding="..."/> in the + * relying party's <SPSSODescriptor>. + * @return the SingleLogoutService Binding + * @since 5.6 + */ + public Saml2MessageBinding getSingleLogoutServiceBinding() { + return this.singleLogoutServiceBinding; + } + + /** + * Get the SingleLogoutService + * Location + * + *

+ * Equivalent to the value found in <SingleLogoutService Location="..."/> in the + * relying party's <SPSSODescriptor>. + * @return the SingleLogoutService Location + * @since 5.6 + */ + public String getSingleLogoutServiceLocation() { + return this.singleLogoutServiceLocation; + } + + /** + * Get the SingleLogoutService + * Response Location + * + *

+ * Equivalent to the value found in <SingleLogoutService + * ResponseLocation="..."/> in the relying party's <SPSSODescriptor>. + * @return the SingleLogoutService Response Location + * @since 5.6 + */ + public String getSingleLogoutServiceResponseLocation() { + return this.singleLogoutServiceResponseLocation; + } + /** * Get the {@link Collection} of decryption {@link Saml2X509Credential}s associated * with this relying party @@ -364,6 +421,9 @@ public static Builder withRelyingPartyRegistration(RelyingPartyRegistration regi .decryptionX509Credentials((c) -> c.addAll(registration.getDecryptionX509Credentials())) .assertionConsumerServiceLocation(registration.getAssertionConsumerServiceLocation()) .assertionConsumerServiceBinding(registration.getAssertionConsumerServiceBinding()) + .singleLogoutServiceLocation(registration.getSingleLogoutServiceLocation()) + .singleLogoutServiceResponseLocation(registration.getSingleLogoutServiceResponseLocation()) + .singleLogoutServiceBinding(registration.getSingleLogoutServiceBinding()) .assertingPartyDetails((assertingParty) -> assertingParty .entityId(registration.getAssertingPartyDetails().getEntityId()) .wantAuthnRequestsSigned(registration.getAssertingPartyDetails().getWantAuthnRequestsSigned()) @@ -376,7 +436,13 @@ public static Builder withRelyingPartyRegistration(RelyingPartyRegistration regi .singleSignOnServiceLocation( registration.getAssertingPartyDetails().getSingleSignOnServiceLocation()) .singleSignOnServiceBinding( - registration.getAssertingPartyDetails().getSingleSignOnServiceBinding())); + registration.getAssertingPartyDetails().getSingleSignOnServiceBinding()) + .singleLogoutServiceLocation( + registration.getAssertingPartyDetails().getSingleLogoutServiceLocation()) + .singleLogoutServiceResponseLocation( + registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation()) + .singleLogoutServiceBinding( + registration.getAssertingPartyDetails().getSingleLogoutServiceBinding())); } private static Saml2X509Credential fromDeprecated( @@ -445,10 +511,17 @@ public static final class AssertingPartyDetails { private final Saml2MessageBinding singleSignOnServiceBinding; + private final String singleLogoutServiceLocation; + + private final String singleLogoutServiceResponseLocation; + + private final Saml2MessageBinding singleLogoutServiceBinding; + private AssertingPartyDetails(String entityId, boolean wantAuthnRequestsSigned, List signingAlgorithms, Collection verificationX509Credentials, Collection encryptionX509Credentials, String singleSignOnServiceLocation, - Saml2MessageBinding singleSignOnServiceBinding) { + Saml2MessageBinding singleSignOnServiceBinding, String singleLogoutServiceLocation, + String singleLogoutServiceResponseLocation, Saml2MessageBinding singleLogoutServiceBinding) { Assert.hasText(entityId, "entityId cannot be null or empty"); Assert.notEmpty(signingAlgorithms, "signingAlgorithms cannot be empty"); Assert.notNull(verificationX509Credentials, "verificationX509Credentials cannot be null"); @@ -472,6 +545,9 @@ private AssertingPartyDetails(String entityId, boolean wantAuthnRequestsSigned, this.encryptionX509Credentials = encryptionX509Credentials; this.singleSignOnServiceLocation = singleSignOnServiceLocation; this.singleSignOnServiceBinding = singleSignOnServiceBinding; + this.singleLogoutServiceLocation = singleLogoutServiceLocation; + this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation; + this.singleLogoutServiceBinding = singleLogoutServiceBinding; } /** @@ -565,6 +641,51 @@ public Saml2MessageBinding getSingleSignOnServiceBinding() { return this.singleSignOnServiceBinding; } + /** + * Get the SingleLogoutService + * Location + * + *

+ * Equivalent to the value found in <SingleLogoutService Location="..."/> in + * the asserting party's <IDPSSODescriptor>. + * @return the SingleLogoutService Location + * @since 5.6 + */ + public String getSingleLogoutServiceLocation() { + return this.singleLogoutServiceLocation; + } + + /** + * Get the SingleLogoutService + * Response Location + * + *

+ * Equivalent to the value found in <SingleLogoutService Location="..."/> in + * the asserting party's <IDPSSODescriptor>. + * @return the SingleLogoutService Response Location + * @since 5.6 + */ + public String getSingleLogoutServiceResponseLocation() { + return this.singleLogoutServiceResponseLocation; + } + + /** + * Get the SingleLogoutService + * Binding + * + *

+ * Equivalent to the value found in <SingleLogoutService Binding="..."/> in + * the asserting party's <IDPSSODescriptor>. + * @return the SingleLogoutService Binding + * @since 5.6 + */ + public Saml2MessageBinding getSingleLogoutServiceBinding() { + return this.singleLogoutServiceBinding; + } + public static final class Builder { private String entityId; @@ -581,6 +702,12 @@ public static final class Builder { private Saml2MessageBinding singleSignOnServiceBinding = Saml2MessageBinding.REDIRECT; + private String singleLogoutServiceLocation; + + private String singleLogoutServiceResponseLocation; + + private Saml2MessageBinding singleLogoutServiceBinding = Saml2MessageBinding.REDIRECT; + /** * Set the asserting party's EntityID. @@ -677,6 +804,59 @@ public Builder singleSignOnServiceBinding(Saml2MessageBinding singleSignOnServic return this; } + /** + * Set the SingleLogoutService + * Location + * + *

+ * Equivalent to the value found in <SingleLogoutService + * Location="..."/> in the asserting party's <IDPSSODescriptor>. + * @param singleLogoutServiceLocation the SingleLogoutService Location + * @return the {@link AssertingPartyDetails.Builder} for further configuration + * @since 5.6 + */ + public Builder singleLogoutServiceLocation(String singleLogoutServiceLocation) { + this.singleLogoutServiceLocation = singleLogoutServiceLocation; + return this; + } + + /** + * Set the SingleLogoutService + * Response Location + * + *

+ * Equivalent to the value found in <SingleLogoutService + * ResponseLocation="..."/> in the asserting party's + * <IDPSSODescriptor>. + * @param singleLogoutServiceResponseLocation the SingleLogoutService Response + * Location + * @return the {@link AssertingPartyDetails.Builder} for further configuration + * @since 5.6 + */ + public Builder singleLogoutServiceResponseLocation(String singleLogoutServiceResponseLocation) { + this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation; + return this; + } + + /** + * Set the SingleLogoutService + * Binding + * + *

+ * Equivalent to the value found in <SingleLogoutService Binding="..."/> + * in the asserting party's <IDPSSODescriptor>. + * @param singleLogoutServiceBinding the SingleLogoutService Binding + * @return the {@link AssertingPartyDetails.Builder} for further configuration + * @since 5.6 + */ + public Builder singleLogoutServiceBinding(Saml2MessageBinding singleLogoutServiceBinding) { + this.singleLogoutServiceBinding = singleLogoutServiceBinding; + return this; + } + /** * Creates an immutable ProviderDetails object representing the configuration * for an Identity Provider, IDP @@ -689,7 +869,9 @@ public AssertingPartyDetails build() { return new AssertingPartyDetails(this.entityId, this.wantAuthnRequestsSigned, signingAlgorithms, this.verificationX509Credentials, this.encryptionX509Credentials, - this.singleSignOnServiceLocation, this.singleSignOnServiceBinding); + this.singleSignOnServiceLocation, this.singleSignOnServiceBinding, + this.singleLogoutServiceLocation, this.singleLogoutServiceResponseLocation, + this.singleLogoutServiceBinding); } } @@ -830,6 +1012,12 @@ public static final class Builder { private Saml2MessageBinding assertionConsumerServiceBinding = Saml2MessageBinding.POST; + private String singleLogoutServiceLocation = "{baseUrl}/logout/saml2/slo"; + + private String singleLogoutServiceResponseLocation; + + private Saml2MessageBinding singleLogoutServiceBinding = Saml2MessageBinding.POST; + private ProviderDetails.Builder providerDetails = new ProviderDetails.Builder(); private Collection credentials = new HashSet<>(); @@ -933,6 +1121,58 @@ public Builder assertionConsumerServiceBinding(Saml2MessageBinding assertionCons return this; } + /** + * Set the SingleLogoutService + * Binding + * + *

+ * Equivalent to the value found in <SingleLogoutService Binding="..."/> in + * the relying party's <SPSSODescriptor>. + * @param singleLogoutServiceBinding the SingleLogoutService Binding + * @return the {@link Builder} for further configuration + * @since 5.6 + */ + public Builder singleLogoutServiceBinding(Saml2MessageBinding singleLogoutServiceBinding) { + this.singleLogoutServiceBinding = singleLogoutServiceBinding; + return this; + } + + /** + * Set the SingleLogoutService + * Location + * + *

+ * Equivalent to the value found in <SingleLogoutService Location="..."/> in + * the relying party's <SPSSODescriptor>. + * @param singleLogoutServiceLocation the SingleLogoutService Location + * @return the {@link Builder} for further configuration + * @since 5.6 + */ + public Builder singleLogoutServiceLocation(String singleLogoutServiceLocation) { + this.singleLogoutServiceLocation = singleLogoutServiceLocation; + return this; + } + + /** + * Set the SingleLogoutService + * Response Location + * + *

+ * Equivalent to the value found in <SingleLogoutService + * ResponseLocation="..."/> in the relying party's <SPSSODescriptor>. + * @param singleLogoutServiceResponseLocation the SingleLogoutService Response + * Location + * @return the {@link Builder} for further configuration + * @since 5.6 + */ + public Builder singleLogoutServiceResponseLocation(String singleLogoutServiceResponseLocation) { + this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation; + return this; + } + /** * Apply this {@link Consumer} to further configure the Asserting Party details * @param assertingPartyDetails The {@link Consumer} to apply @@ -1075,10 +1315,14 @@ public RelyingPartyRegistration build() { for (Saml2X509Credential credential : this.providerDetails.assertingPartyDetailsBuilder.encryptionX509Credentials) { this.credentials.add(toDeprecated(credential)); } + if (this.singleLogoutServiceResponseLocation == null) { + this.singleLogoutServiceResponseLocation = this.singleLogoutServiceLocation; + } return new RelyingPartyRegistration(this.registrationId, this.entityId, this.assertionConsumerServiceLocation, this.assertionConsumerServiceBinding, - this.providerDetails.build(), this.credentials, this.decryptionX509Credentials, - this.signingX509Credentials); + this.singleLogoutServiceLocation, this.singleLogoutServiceResponseLocation, + this.singleLogoutServiceBinding, this.providerDetails.build(), this.credentials, + this.decryptionX509Credentials, this.signingX509Credentials); } } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java index ce8ae7e4489..cc5f77c318d 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java @@ -45,7 +45,7 @@ * @since 5.4 */ public final class DefaultRelyingPartyRegistrationResolver - implements RelyingPartyRegistrationResolver, Converter { + implements Converter, RelyingPartyRegistrationResolver { private Log logger = LogFactory.getLog(getClass()); @@ -98,9 +98,14 @@ public RelyingPartyRegistration resolve(HttpServletRequest request, String relyi String relyingPartyEntityId = templateResolver.apply(relyingPartyRegistration.getEntityId()); String assertionConsumerServiceLocation = templateResolver .apply(relyingPartyRegistration.getAssertionConsumerServiceLocation()); + String singleLogoutServiceLocation = templateResolver + .apply(relyingPartyRegistration.getSingleLogoutServiceLocation()); + String singleLogoutServiceResponseLocation = templateResolver + .apply(relyingPartyRegistration.getSingleLogoutServiceResponseLocation()); return RelyingPartyRegistration.withRelyingPartyRegistration(relyingPartyRegistration) .entityId(relyingPartyEntityId).assertionConsumerServiceLocation(assertionConsumerServiceLocation) - .build(); + .singleLogoutServiceLocation(singleLogoutServiceLocation) + .singleLogoutServiceResponseLocation(singleLogoutServiceResponseLocation).build(); } private Function templateResolver(String applicationUri, RelyingPartyRegistration relyingParty) { @@ -108,6 +113,9 @@ private Function templateResolver(String applicationUri, Relying } private static String resolveUrlTemplate(String template, String baseUrl, RelyingPartyRegistration relyingParty) { + if (template == null) { + return null; + } String entityId = relyingParty.getAssertingPartyDetails().getEntityId(); String registrationId = relyingParty.getRegistrationId(); Map uriVariables = new HashMap<>(); diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java new file mode 100644 index 00000000000..280d175bdaa --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java @@ -0,0 +1,105 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.security.MessageDigest; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.springframework.security.crypto.codec.Utf8; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.util.Assert; + +/** + * An implementation of an {@link Saml2LogoutRequestRepository} that stores + * {@link Saml2LogoutRequest} in the {@code HttpSession}. + * + * @author Josh Cummings + * @since 5.6 + * @see Saml2LogoutRequestRepository + * @see Saml2LogoutRequest + */ +public final class HttpSessionLogoutRequestRepository implements Saml2LogoutRequestRepository { + + private static final String DEFAULT_LOGOUT_REQUEST_ATTR_NAME = HttpSessionLogoutRequestRepository.class.getName() + + ".LOGOUT_REQUEST"; + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutRequest loadLogoutRequest(HttpServletRequest request) { + Assert.notNull(request, "request cannot be null"); + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + Saml2LogoutRequest logoutRequest = (Saml2LogoutRequest) session.getAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME); + if (stateParameterEquals(request, logoutRequest)) { + return logoutRequest; + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void saveLogoutRequest(Saml2LogoutRequest logoutRequest, HttpServletRequest request, + HttpServletResponse response) { + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); + if (logoutRequest == null) { + request.getSession().removeAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME); + return; + } + String state = logoutRequest.getRelayState(); + Assert.hasText(state, "logoutRequest.state cannot be empty"); + request.getSession().setAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME, logoutRequest); + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutRequest removeLogoutRequest(HttpServletRequest request, HttpServletResponse response) { + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); + Saml2LogoutRequest logoutRequest = loadLogoutRequest(request); + if (logoutRequest == null) { + return null; + } + request.getSession().removeAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME); + return logoutRequest; + } + + private String getStateParameter(HttpServletRequest request) { + return request.getParameter("RelayState"); + } + + private boolean stateParameterEquals(HttpServletRequest request, Saml2LogoutRequest logoutRequest) { + String stateParameter = getStateParameter(request); + if (stateParameter == null || logoutRequest == null) { + return false; + } + String relayState = logoutRequest.getRelayState(); + return MessageDigest.isEqual(Utf8.encode(stateParameter), Utf8.encode(relayState)); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java new file mode 100644 index 00000000000..badbf548fbf --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java @@ -0,0 +1,167 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.function.BiConsumer; + +import javax.servlet.http.HttpServletRequest; + +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.opensaml.core.config.ConfigurationService; +import org.opensaml.core.xml.config.XMLObjectProviderRegistry; +import org.opensaml.core.xml.io.MarshallingException; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.impl.IssuerBuilder; +import org.opensaml.saml.saml2.core.impl.LogoutRequestBuilder; +import org.opensaml.saml.saml2.core.impl.LogoutRequestMarshaller; +import org.opensaml.saml.saml2.core.impl.NameIDBuilder; +import org.w3c.dom.Element; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.OpenSamlInitializationService; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial; +import org.springframework.util.Assert; + +/** + * For internal use only. Intended for consolidating common behavior related to minting a + * SAML 2.0 Logout Request. + */ +final class OpenSamlLogoutRequestResolver { + + static { + OpenSamlInitializationService.initialize(); + } + + private final Log logger = LogFactory.getLog(getClass()); + + private final LogoutRequestMarshaller marshaller; + + private final IssuerBuilder issuerBuilder; + + private final NameIDBuilder nameIdBuilder; + + private final LogoutRequestBuilder logoutRequestBuilder; + + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; + + /** + * Construct a {@link OpenSamlLogoutRequestResolver} + */ + OpenSamlLogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; + XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class); + this.marshaller = (LogoutRequestMarshaller) registry.getMarshallerFactory() + .getMarshaller(LogoutRequest.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.marshaller, "logoutRequestMarshaller must be configured in OpenSAML"); + this.logoutRequestBuilder = (LogoutRequestBuilder) registry.getBuilderFactory() + .getBuilder(LogoutRequest.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.logoutRequestBuilder, "logoutRequestBuilder must be configured in OpenSAML"); + this.issuerBuilder = (IssuerBuilder) registry.getBuilderFactory().getBuilder(Issuer.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.issuerBuilder, "issuerBuilder must be configured in OpenSAML"); + this.nameIdBuilder = (NameIDBuilder) registry.getBuilderFactory().getBuilder(NameID.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.nameIdBuilder, "nameIdBuilder must be configured in OpenSAML"); + } + + /** + * Prepare to create, sign, and serialize a SAML 2.0 Logout Request. + * + * By default, includes a {@code NameID} based on the {@link Authentication} instance + * as well as the {@code Destination} and {@code Issuer} based on the + * {@link RelyingPartyRegistration} derived from the {@link Authentication}. + * @param request the HTTP request + * @param authentication the current user + * @return a signed and serialized SAML 2.0 Logout Request + */ + Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication) { + return resolve(request, authentication, (registration, logoutRequest) -> { + }); + } + + Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication, + BiConsumer logoutRequestConsumer) { + String registrationId = getRegistrationId(authentication); + RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, registrationId); + if (registration == null) { + return null; + } + LogoutRequest logoutRequest = this.logoutRequestBuilder.buildObject(); + logoutRequest.setDestination(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation()); + Issuer issuer = this.issuerBuilder.buildObject(); + issuer.setValue(registration.getEntityId()); + logoutRequest.setIssuer(issuer); + NameID nameId = this.nameIdBuilder.buildObject(); + nameId.setValue(authentication.getName()); + logoutRequest.setNameID(nameId); + logoutRequestConsumer.accept(registration, logoutRequest); + if (logoutRequest.getID() == null) { + logoutRequest.setID("LR" + UUID.randomUUID()); + } + String relayState = UUID.randomUUID().toString(); + Saml2LogoutRequest.Builder result = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .id(logoutRequest.getID()); + if (registration.getAssertingPartyDetails().getSingleLogoutServiceBinding() == Saml2MessageBinding.POST) { + String xml = serialize(OpenSamlSigningUtils.sign(logoutRequest, registration)); + String samlRequest = Saml2Utils.samlEncode(xml.getBytes(StandardCharsets.UTF_8)); + return result.samlRequest(samlRequest).relayState(relayState).build(); + } + else { + String xml = serialize(logoutRequest); + String deflatedAndEncoded = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(xml)); + result.samlRequest(deflatedAndEncoded); + QueryParametersPartial partial = OpenSamlSigningUtils.sign(registration) + .param("SAMLRequest", deflatedAndEncoded).param("RelayState", relayState); + return result.parameters((params) -> params.putAll(partial.parameters())).build(); + } + } + + private String getRegistrationId(Authentication authentication) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Attempting to resolve registrationId from " + authentication); + } + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof Saml2AuthenticatedPrincipal) { + return ((Saml2AuthenticatedPrincipal) principal).getRelyingPartyRegistrationId(); + } + return null; + } + + private String serialize(LogoutRequest logoutRequest) { + try { + Element element = this.marshaller.marshall(logoutRequest); + return SerializeSupport.nodeToString(element); + } + catch (MarshallingException ex) { + throw new Saml2Exception(ex); + } + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java new file mode 100644 index 00000000000..bca2affad94 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java @@ -0,0 +1,218 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.function.BiConsumer; + +import javax.servlet.http.HttpServletRequest; + +import net.shibboleth.utilities.java.support.xml.ParserPool; +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.opensaml.core.config.ConfigurationService; +import org.opensaml.core.xml.config.XMLObjectProviderRegistry; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.core.xml.io.MarshallingException; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.LogoutResponse; +import org.opensaml.saml.saml2.core.Status; +import org.opensaml.saml.saml2.core.StatusCode; +import org.opensaml.saml.saml2.core.impl.IssuerBuilder; +import org.opensaml.saml.saml2.core.impl.LogoutRequestUnmarshaller; +import org.opensaml.saml.saml2.core.impl.LogoutResponseBuilder; +import org.opensaml.saml.saml2.core.impl.LogoutResponseMarshaller; +import org.opensaml.saml.saml2.core.impl.StatusBuilder; +import org.opensaml.saml.saml2.core.impl.StatusCodeBuilder; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.OpenSamlInitializationService; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial; +import org.springframework.util.Assert; + +/** + * For internal use only. Intended for consolidating common behavior related to minting a + * SAML 2.0 Logout Response. + */ +final class OpenSamlLogoutResponseResolver { + + static { + OpenSamlInitializationService.initialize(); + } + + private final Log logger = LogFactory.getLog(getClass()); + + private final ParserPool parserPool; + + private final LogoutRequestUnmarshaller unmarshaller; + + private final LogoutResponseMarshaller marshaller; + + private final LogoutResponseBuilder logoutResponseBuilder; + + private final IssuerBuilder issuerBuilder; + + private final StatusBuilder statusBuilder; + + private final StatusCodeBuilder statusCodeBuilder; + + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; + + /** + * Construct a {@link OpenSamlLogoutResponseResolver} + */ + OpenSamlLogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; + XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class); + this.parserPool = registry.getParserPool(); + this.unmarshaller = (LogoutRequestUnmarshaller) XMLObjectProviderRegistrySupport.getUnmarshallerFactory() + .getUnmarshaller(LogoutRequest.DEFAULT_ELEMENT_NAME); + this.marshaller = (LogoutResponseMarshaller) registry.getMarshallerFactory() + .getMarshaller(LogoutResponse.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.marshaller, "logoutResponseMarshaller must be configured in OpenSAML"); + this.logoutResponseBuilder = (LogoutResponseBuilder) registry.getBuilderFactory() + .getBuilder(LogoutResponse.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.logoutResponseBuilder, "logoutResponseBuilder must be configured in OpenSAML"); + this.issuerBuilder = (IssuerBuilder) registry.getBuilderFactory().getBuilder(Issuer.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.issuerBuilder, "issuerBuilder must be configured in OpenSAML"); + this.statusBuilder = (StatusBuilder) registry.getBuilderFactory().getBuilder(Status.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.statusBuilder, "statusBuilder must be configured in OpenSAML"); + this.statusCodeBuilder = (StatusCodeBuilder) registry.getBuilderFactory() + .getBuilder(StatusCode.DEFAULT_ELEMENT_NAME); + Assert.notNull(this.statusCodeBuilder, "statusCodeBuilder must be configured in OpenSAML"); + } + + /** + * Prepare to create, sign, and serialize a SAML 2.0 Logout Response. + * + * By default, includes a {@code RelayState} based on the {@link HttpServletRequest} + * as well as the {@code Destination} and {@code Issuer} based on the + * {@link RelyingPartyRegistration} derived from the {@link Authentication}. The + * logout response is also marked as {@code SUCCESS}. + * @param request the HTTP request + * @param authentication the current user + * @return a signed and serialized SAML 2.0 Logout Response + */ + Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication) { + return resolve(request, authentication, (registration, logoutResponse) -> { + }); + } + + Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication, + BiConsumer logoutResponseConsumer) { + String registrationId = getRegistrationId(authentication); + RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, registrationId); + if (registration == null) { + return null; + } + String serialized = request.getParameter("SAMLRequest"); + byte[] b = Saml2Utils.samlDecode(serialized); + LogoutRequest logoutRequest = parse(inflateIfRequired(registration, b)); + LogoutResponse logoutResponse = this.logoutResponseBuilder.buildObject(); + logoutResponse.setDestination(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation()); + Issuer issuer = this.issuerBuilder.buildObject(); + issuer.setValue(registration.getEntityId()); + logoutResponse.setIssuer(issuer); + StatusCode code = this.statusCodeBuilder.buildObject(); + code.setValue(StatusCode.SUCCESS); + Status status = this.statusBuilder.buildObject(); + status.setStatusCode(code); + logoutResponse.setStatus(status); + logoutResponse.setInResponseTo(logoutRequest.getID()); + if (logoutResponse.getID() == null) { + logoutResponse.setID("LR" + UUID.randomUUID()); + } + logoutResponseConsumer.accept(registration, logoutResponse); + Saml2LogoutResponse.Builder result = Saml2LogoutResponse.withRelyingPartyRegistration(registration); + if (registration.getAssertingPartyDetails().getSingleLogoutServiceBinding() == Saml2MessageBinding.POST) { + String xml = serialize(OpenSamlSigningUtils.sign(logoutResponse, registration)); + String samlResponse = Saml2Utils.samlEncode(xml.getBytes(StandardCharsets.UTF_8)); + result.samlResponse(samlResponse); + if (request.getParameter("RelayState") != null) { + result.relayState(request.getParameter("RelayState")); + } + return result.build(); + } + else { + String xml = serialize(logoutResponse); + String deflatedAndEncoded = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(xml)); + result.samlResponse(deflatedAndEncoded); + QueryParametersPartial partial = OpenSamlSigningUtils.sign(registration).param("SAMLResponse", + deflatedAndEncoded); + if (request.getParameter("RelayState") != null) { + partial.param("RelayState", request.getParameter("RelayState")); + } + return result.parameters((params) -> params.putAll(partial.parameters())).build(); + } + } + + private String getRegistrationId(Authentication authentication) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Attempting to resolve registrationId from " + authentication); + } + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof Saml2AuthenticatedPrincipal) { + return ((Saml2AuthenticatedPrincipal) principal).getRelyingPartyRegistrationId(); + } + return null; + } + + private String inflateIfRequired(RelyingPartyRegistration registration, byte[] b) { + if (registration.getSingleLogoutServiceBinding() == Saml2MessageBinding.REDIRECT) { + return Saml2Utils.samlInflate(b); + } + return new String(b, StandardCharsets.UTF_8); + } + + private LogoutRequest parse(String request) throws Saml2Exception { + try { + Document document = this.parserPool + .parse(new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8))); + Element element = document.getDocumentElement(); + return (LogoutRequest) this.unmarshaller.unmarshall(element); + } + catch (Exception ex) { + throw new Saml2Exception("Failed to deserialize LogoutRequest", ex); + } + } + + private String serialize(LogoutResponse logoutResponse) { + try { + Element element = this.marshaller.marshall(logoutResponse); + return SerializeSupport.nodeToString(element); + } + catch (MarshallingException ex) { + throw new Saml2Exception(ex); + } + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlSigningUtils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlSigningUtils.java new file mode 100644 index 00000000000..12ad6769b10 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlSigningUtils.java @@ -0,0 +1,173 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.shibboleth.utilities.java.support.resolver.CriteriaSet; +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.core.xml.io.Marshaller; +import org.opensaml.core.xml.io.MarshallingException; +import org.opensaml.saml.security.impl.SAMLMetadataSignatureSigningParametersResolver; +import org.opensaml.security.SecurityException; +import org.opensaml.security.credential.BasicCredential; +import org.opensaml.security.credential.Credential; +import org.opensaml.security.credential.CredentialSupport; +import org.opensaml.security.credential.UsageType; +import org.opensaml.xmlsec.SignatureSigningParameters; +import org.opensaml.xmlsec.SignatureSigningParametersResolver; +import org.opensaml.xmlsec.criterion.SignatureSigningConfigurationCriterion; +import org.opensaml.xmlsec.crypto.XMLSigningUtil; +import org.opensaml.xmlsec.impl.BasicSignatureSigningConfiguration; +import org.opensaml.xmlsec.signature.SignableXMLObject; +import org.opensaml.xmlsec.signature.support.SignatureConstants; +import org.opensaml.xmlsec.signature.support.SignatureSupport; +import org.w3c.dom.Element; + +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +/** + * Utility methods for signing SAML components with OpenSAML + * + * For internal use only. + * + * @author Josh Cummings + */ +final class OpenSamlSigningUtils { + + static String serialize(XMLObject object) { + try { + Marshaller marshaller = XMLObjectProviderRegistrySupport.getMarshallerFactory().getMarshaller(object); + Element element = marshaller.marshall(object); + return SerializeSupport.nodeToString(element); + } + catch (MarshallingException ex) { + throw new Saml2Exception(ex); + } + } + + static O sign(O object, RelyingPartyRegistration relyingPartyRegistration) { + SignatureSigningParameters parameters = resolveSigningParameters(relyingPartyRegistration); + try { + SignatureSupport.signObject(object, parameters); + return object; + } + catch (Exception ex) { + throw new Saml2Exception(ex); + } + } + + static QueryParametersPartial sign(RelyingPartyRegistration registration) { + return new QueryParametersPartial(registration); + } + + private static SignatureSigningParameters resolveSigningParameters( + RelyingPartyRegistration relyingPartyRegistration) { + List credentials = resolveSigningCredentials(relyingPartyRegistration); + List algorithms = relyingPartyRegistration.getAssertingPartyDetails().getSigningAlgorithms(); + List digests = Collections.singletonList(SignatureConstants.ALGO_ID_DIGEST_SHA256); + String canonicalization = SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS; + SignatureSigningParametersResolver resolver = new SAMLMetadataSignatureSigningParametersResolver(); + CriteriaSet criteria = new CriteriaSet(); + BasicSignatureSigningConfiguration signingConfiguration = new BasicSignatureSigningConfiguration(); + signingConfiguration.setSigningCredentials(credentials); + signingConfiguration.setSignatureAlgorithms(algorithms); + signingConfiguration.setSignatureReferenceDigestMethods(digests); + signingConfiguration.setSignatureCanonicalizationAlgorithm(canonicalization); + criteria.add(new SignatureSigningConfigurationCriterion(signingConfiguration)); + try { + SignatureSigningParameters parameters = resolver.resolveSingle(criteria); + Assert.notNull(parameters, "Failed to resolve any signing credential"); + return parameters; + } + catch (Exception ex) { + throw new Saml2Exception(ex); + } + } + + private static List resolveSigningCredentials(RelyingPartyRegistration relyingPartyRegistration) { + List credentials = new ArrayList<>(); + for (Saml2X509Credential x509Credential : relyingPartyRegistration.getSigningX509Credentials()) { + X509Certificate certificate = x509Credential.getCertificate(); + PrivateKey privateKey = x509Credential.getPrivateKey(); + BasicCredential credential = CredentialSupport.getSimpleCredential(certificate, privateKey); + credential.setEntityId(relyingPartyRegistration.getEntityId()); + credential.setUsageType(UsageType.SIGNING); + credentials.add(credential); + } + return credentials; + } + + static class QueryParametersPartial { + + final RelyingPartyRegistration registration; + + final Map components = new LinkedHashMap<>(); + + QueryParametersPartial(RelyingPartyRegistration registration) { + this.registration = registration; + } + + QueryParametersPartial param(String key, String value) { + this.components.put(key, value); + return this; + } + + Map parameters() { + SignatureSigningParameters parameters = resolveSigningParameters(this.registration); + Credential credential = parameters.getSigningCredential(); + String algorithmUri = parameters.getSignatureAlgorithm(); + this.components.put("SigAlg", algorithmUri); + UriComponentsBuilder builder = UriComponentsBuilder.newInstance(); + for (Map.Entry component : this.components.entrySet()) { + builder.queryParam(component.getKey(), + UriUtils.encode(component.getValue(), StandardCharsets.ISO_8859_1)); + } + String queryString = builder.build(true).toString().substring(1); + try { + byte[] rawSignature = XMLSigningUtil.signWithURI(credential, algorithmUri, + queryString.getBytes(StandardCharsets.UTF_8)); + String b64Signature = Saml2Utils.samlEncode(rawSignature); + this.components.put("Signature", b64Signature); + } + catch (SecurityException ex) { + throw new Saml2Exception(ex); + } + return this.components; + } + + } + + private OpenSamlSigningUtils() { + + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java new file mode 100644 index 00000000000..ab568a55fdf --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java @@ -0,0 +1,250 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.function.Function; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +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.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidatorParameters; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.authentication.logout.CompositeLogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +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 + * from the asserting party. + * + * @author Josh Cummings + * @since 5.6 + * @see Saml2LogoutRequestValidator + * @see Saml2AssertingPartyInitiatedLogoutSuccessHandler + */ +public final class Saml2LogoutRequestFilter extends OncePerRequestFilter { + + private final Log logger = LogFactory.getLog(getClass()); + + private final Saml2LogoutRequestValidator logoutRequestValidator; + + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; + + private final Saml2LogoutResponseResolver logoutResponseResolver; + + private final LogoutHandler handler; + + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private RequestMatcher logoutRequestMatcher = new AntPathRequestMatcher("/logout/saml2/slo"); + + /** + * Constructs a {@link Saml2LogoutResponseFilter} for accepting SAML 2.0 Logout + * Requests from the asserting party + * @param relyingPartyRegistrationResolver the strategy for resolving a + * {@link RelyingPartyRegistration} + * @param logoutRequestValidator the SAML 2.0 Logout Request authenticator + * @param logoutResponseResolver the strategy for creating a SAML 2.0 Logout Response + * @param handlers the actions that perform logout + */ + public Saml2LogoutRequestFilter(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver, + Saml2LogoutRequestValidator logoutRequestValidator, Saml2LogoutResponseResolver logoutResponseResolver, + LogoutHandler... handlers) { + this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; + this.logoutRequestValidator = logoutRequestValidator; + this.logoutResponseResolver = logoutResponseResolver; + this.handler = new CompositeLogoutHandler(handlers); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + if (!this.logoutRequestMatcher.matches(request)) { + chain.doFilter(request, response); + return; + } + + if (request.getParameter("SAMLRequest") == null) { + chain.doFilter(request, response); + return; + } + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, + getRegistrationId(authentication)); + if (registration == null) { + this.logger + .trace("Did not process logout request since failed to find associated RelyingPartyRegistration"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return; + } + if (!isCorrectBinding(request, registration)) { + this.logger.trace("Did not process logout request since used incorrect binding"); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String serialized = request.getParameter("SAMLRequest"); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest(serialized).relayState(request.getParameter("RelayState")) + .binding(registration.getSingleLogoutServiceBinding()) + .location(registration.getSingleLogoutServiceLocation()) + .parameters((params) -> params.put("SigAlg", request.getParameter("SigAlg"))) + .parameters((params) -> params.put("Signature", request.getParameter("Signature"))).build(); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(logoutRequest, + registration, authentication); + Saml2LogoutValidatorResult result = this.logoutRequestValidator.validate(parameters); + if (result.hasErrors()) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, result.getErrors().iterator().next().toString()); + this.logger.debug(LogMessage.format("Failed to validate LogoutRequest: %s", result.getErrors())); + return; + } + this.handler.logout(request, response, authentication); + Saml2LogoutResponse logoutResponse = this.logoutResponseResolver.resolve(request, authentication); + if (logoutResponse == null) { + this.logger.trace("Returning 401 since no logout response generated"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + if (logoutResponse.getBinding() == Saml2MessageBinding.REDIRECT) { + doRedirect(request, response, logoutResponse); + } + else { + doPost(response, logoutResponse); + } + } + + public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) { + Assert.notNull(logoutRequestMatcher, "logoutRequestMatcher cannot be null"); + this.logoutRequestMatcher = logoutRequestMatcher; + } + + private String getRegistrationId(Authentication authentication) { + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof Saml2AuthenticatedPrincipal) { + return ((Saml2AuthenticatedPrincipal) principal).getRelyingPartyRegistrationId(); + } + return null; + } + + private boolean isCorrectBinding(HttpServletRequest request, RelyingPartyRegistration registration) { + Saml2MessageBinding requiredBinding = registration.getSingleLogoutServiceBinding(); + if (requiredBinding == Saml2MessageBinding.POST) { + return "POST".equals(request.getMethod()); + } + return "GET".equals(request.getMethod()); + } + + private void doRedirect(HttpServletRequest request, HttpServletResponse response, + Saml2LogoutResponse logoutResponse) throws IOException { + String location = logoutResponse.getResponseLocation(); + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(location); + addParameter("SAMLResponse", logoutResponse::getParameter, uriBuilder); + addParameter("RelayState", logoutResponse::getParameter, uriBuilder); + addParameter("SigAlg", logoutResponse::getParameter, uriBuilder); + addParameter("Signature", logoutResponse::getParameter, uriBuilder); + this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString()); + } + + private void addParameter(String name, Function parameters, UriComponentsBuilder builder) { + Assert.hasText(name, "name cannot be empty or null"); + if (StringUtils.hasText(parameters.apply(name))) { + builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1), + UriUtils.encode(parameters.apply(name), StandardCharsets.ISO_8859_1)); + } + } + + private void doPost(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); + } + + private String createSamlPostRequestFormData(String location, String saml, String relayState) { + StringBuilder html = new StringBuilder(); + html.append("\n"); + html.append("\n").append(" \n"); + html.append(" \n"); + html.append(" \n"); + html.append(" \n"); + html.append("

\n"); + html.append(" Note: Since your browser does not support JavaScript,\n"); + html.append(" you must press the Continue button once to proceed.\n"); + html.append("

\n"); + html.append(" \n"); + html.append(" \n"); + html.append("
\n"); + html.append("
\n"); + html.append(" \n"); + if (StringUtils.hasText(relayState)) { + html.append(" \n"); + } + html.append("
\n"); + html.append(" \n"); + html.append("
\n"); + html.append(" \n"); + html.append(" \n"); + html.append(""); + return html.toString(); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java new file mode 100644 index 00000000000..f977ce84b84 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java @@ -0,0 +1,68 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; + +/** + * Implementations of this interface are responsible for the persistence of + * {@link Saml2LogoutRequest} between requests. + * + *

+ * Used by the {@link Saml2RelyingPartyInitiatedLogoutSuccessHandler} for persisting the + * Logout Request before it initiates the SAML 2.0 SLO flow. As well, used by + * {@code OpenSamlLogoutResponseHandler} for resolving the Logout Request associated with + * that Logout Response. + * + * @author Josh Cummings + * @since 5.6 + * @see Saml2LogoutRequest + * @see HttpSessionLogoutRequestRepository + */ +public interface Saml2LogoutRequestRepository { + + /** + * Returns the {@link Saml2LogoutRequest} associated to the provided + * {@code HttpServletRequest} or {@code null} if not available. + * @param request the {@code HttpServletRequest} + * @return the {@link Saml2LogoutRequest} or {@code null} if not available + */ + Saml2LogoutRequest loadLogoutRequest(HttpServletRequest request); + + /** + * Persists the {@link Saml2LogoutRequest} associating it to the provided + * {@code HttpServletRequest} and/or {@code HttpServletResponse}. + * @param logoutRequest the {@link Saml2LogoutRequest} + * @param request the {@code HttpServletRequest} + * @param response the {@code HttpServletResponse} + */ + void saveLogoutRequest(Saml2LogoutRequest logoutRequest, HttpServletRequest request, HttpServletResponse response); + + /** + * Removes and returns the {@link Saml2LogoutRequest} associated to the provided + * {@code HttpServletRequest} and {@code HttpServletResponse} or if not available + * returns {@code null}. + * @param request the {@code HttpServletRequest} + * @param response the {@code HttpServletResponse} + * @return the {@link Saml2LogoutRequest} or {@code null} if not available + */ + Saml2LogoutRequest removeLogoutRequest(HttpServletRequest request, HttpServletResponse response); + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java new file mode 100644 index 00000000000..d4b5e4e3b27 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java @@ -0,0 +1,49 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; + +/** + * Creates a signed SAML 2.0 Logout Request based on information from the + * {@link HttpServletRequest} and current {@link Authentication}. + * + * The returned logout request is suitable for sending to the asserting party based on, + * for example, the location and binding specified in + * {@link RelyingPartyRegistration#getAssertingPartyDetails()}. + * + * @author Josh Cummings + * @since 5.6 + * @see RelyingPartyRegistration + */ +public interface Saml2LogoutRequestResolver { + + /** + * Prepare to create, sign, and serialize a SAML 2.0 Logout Request. + * + * By default, includes a {@code NameID} based on the {@link Authentication} instance. + * @param request the HTTP request + * @param authentication the current user + * @return a signed and serialized SAML 2.0 Logout Request + */ + Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication); + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java new file mode 100644 index 00000000000..f15ab329246 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java @@ -0,0 +1,169 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.saml2.core.Saml2Error; +import org.springframework.security.saml2.core.Saml2ErrorCodes; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidatorParameters; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * A filter for handling a <saml2:LogoutResponse> sent from the asserting party. A + * <saml2:LogoutResponse> is sent in response to a <saml2:LogoutRequest> + * already sent by the relying party. + * + * Note that before a <saml2:LogoutRequest> is sent, the user is logged out. Given + * that, this implementation should not use any {@link LogoutSuccessHandler} that relies + * on the user being logged in. + * + * @author Josh Cummings + * @since 5.6 + * @see Saml2LogoutRequestRepository + * @see Saml2LogoutResponseValidator + */ +public final class Saml2LogoutResponseFilter extends OncePerRequestFilter { + + private final Log logger = LogFactory.getLog(getClass()); + + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; + + private final Saml2LogoutResponseValidator logoutResponseValidator; + + private final LogoutSuccessHandler logoutSuccessHandler; + + private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository(); + + private RequestMatcher logoutRequestMatcher = new AntPathRequestMatcher("/logout/saml2/slo"); + + /** + * Constructs a {@link Saml2LogoutResponseFilter} for accepting SAML 2.0 Logout + * Responses from the asserting party + * @param relyingPartyRegistrationResolver the strategy for resolving a + * {@link RelyingPartyRegistration} + * @param logoutResponseValidator authenticates the SAML 2.0 Logout Response + * @param logoutSuccessHandler the action to perform now that logout has succeeded + */ + public Saml2LogoutResponseFilter(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver, + Saml2LogoutResponseValidator logoutResponseValidator, LogoutSuccessHandler logoutSuccessHandler) { + this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; + this.logoutResponseValidator = logoutResponseValidator; + this.logoutSuccessHandler = logoutSuccessHandler; + } + + /** + * {@inheritDoc} + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + if (!this.logoutRequestMatcher.matches(request)) { + chain.doFilter(request, response); + return; + } + + if (request.getParameter("SAMLResponse") == null) { + chain.doFilter(request, response); + return; + } + + Saml2LogoutRequest logoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response); + if (logoutRequest == null) { + this.logger.trace("Did not process logout response since could not find associated LogoutRequest"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Failed to find associated LogoutRequest"); + return; + } + RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, + logoutRequest.getRelyingPartyRegistrationId()); + if (registration == null) { + this.logger + .trace("Did not process logout request since failed to find associated RelyingPartyRegistration"); + Saml2Error error = new Saml2Error(Saml2ErrorCodes.RELYING_PARTY_REGISTRATION_NOT_FOUND, + "Failed to find associated RelyingPartyRegistration"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, error.toString()); + return; + } + if (!isCorrectBinding(request, registration)) { + this.logger.trace("Did not process logout request since used incorrect binding"); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String serialized = request.getParameter("SAMLResponse"); + Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration) + .samlResponse(serialized).relayState(request.getParameter("RelayState")) + .binding(registration.getSingleLogoutServiceBinding()) + .location(registration.getSingleLogoutServiceResponseLocation()) + .parameters((params) -> params.put("SigAlg", request.getParameter("SigAlg"))) + .parameters((params) -> params.put("Signature", request.getParameter("Signature"))).build(); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(logoutResponse, + logoutRequest, registration); + Saml2LogoutValidatorResult result = this.logoutResponseValidator.validate(parameters); + if (result.hasErrors()) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, result.getErrors().iterator().next().toString()); + this.logger.debug(LogMessage.format("Failed to validate LogoutResponse: %s", result.getErrors())); + return; + } + this.logoutSuccessHandler.onLogoutSuccess(request, response, null); + } + + public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) { + Assert.notNull(logoutRequestMatcher, "logoutRequestMatcher cannot be null"); + this.logoutRequestMatcher = logoutRequestMatcher; + } + + /** + * Use this {@link Saml2LogoutRequestRepository} for retrieving the SAML 2.0 Logout + * Request associated with the request's {@code RelayState} + * @param logoutRequestRepository the {@link Saml2LogoutRequestRepository} to use + */ + public void setLogoutRequestRepository(Saml2LogoutRequestRepository logoutRequestRepository) { + Assert.notNull(logoutRequestRepository, "logoutRequestRepository cannot be null"); + this.logoutRequestRepository = logoutRequestRepository; + } + + private boolean isCorrectBinding(HttpServletRequest request, RelyingPartyRegistration registration) { + Saml2MessageBinding requiredBinding = registration.getSingleLogoutServiceBinding(); + if (requiredBinding == Saml2MessageBinding.POST) { + return "POST".equals(request.getMethod()); + } + return "GET".equals(request.getMethod()); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java new file mode 100644 index 00000000000..a47b39f8eb0 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java @@ -0,0 +1,47 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; + +/** + * Creates a signed SAML 2.0 Logout Response based on information from the + * {@link HttpServletRequest} and current {@link Authentication}. + * + * The returned logout response is suitable for sending to the asserting party based on, + * for example, the location and binding specified in + * {@link RelyingPartyRegistration#getAssertingPartyDetails()}. + * + * @author Josh Cummings + * @since 5.6 + * @see RelyingPartyRegistration + */ +public interface Saml2LogoutResponseResolver { + + /** + * Prepare to create, sign, and serialize a SAML 2.0 Logout Response. + * @param request the HTTP request + * @param authentication the current user + * @return a signed and serialized SAML 2.0 Logout Response + */ + Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication); + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandler.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandler.java new file mode 100644 index 00000000000..5e367714a93 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandler.java @@ -0,0 +1,171 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.function.Function; + +import javax.servlet.http.HttpServletRequest; +import javax.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.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.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 the SAML 2.0 Asserting + * Party + * + * @author Josh Cummings + * @since 5.6 + */ +public final class Saml2RelyingPartyInitiatedLogoutSuccessHandler implements LogoutSuccessHandler { + + private final Log logger = LogFactory.getLog(getClass()); + + private final Saml2LogoutRequestResolver logoutRequestResolver; + + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository(); + + /** + * Constructs a {@link Saml2RelyingPartyInitiatedLogoutSuccessHandler} using the + * provided parameters + * @param logoutRequestResolver the {@link Saml2LogoutRequestResolver} to use + */ + public Saml2RelyingPartyInitiatedLogoutSuccessHandler(Saml2LogoutRequestResolver logoutRequestResolver) { + this.logoutRequestResolver = logoutRequestResolver; + } + + /** + * Produce and send a SAML 2.0 Logout Response based on the SAML 2.0 Logout Request + * received from the asserting party + * @param request the HTTP request + * @param response the HTTP response + * @param authentication the current principal details + * @throws IOException when failing to write to the response + */ + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException { + Saml2LogoutRequest logoutRequest = this.logoutRequestResolver.resolve(request, authentication); + if (logoutRequest == null) { + this.logger.trace("Returning 401 since no logout request generated"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response); + if (logoutRequest.getBinding() == Saml2MessageBinding.REDIRECT) { + doRedirect(request, response, logoutRequest); + } + else { + doPost(response, logoutRequest); + } + } + + /** + * Use this {@link Saml2LogoutRequestRepository} for saving the SAML 2.0 Logout + * Request + * @param logoutRequestRepository the {@link Saml2LogoutRequestRepository} to use + */ + public void setLogoutRequestRepository(Saml2LogoutRequestRepository logoutRequestRepository) { + Assert.notNull(logoutRequestRepository, "logoutRequestRepository cannot be null"); + this.logoutRequestRepository = logoutRequestRepository; + } + + private void doRedirect(HttpServletRequest request, HttpServletResponse response, Saml2LogoutRequest logoutRequest) + throws IOException { + String location = logoutRequest.getLocation(); + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(location); + addParameter("SAMLRequest", logoutRequest::getParameter, uriBuilder); + addParameter("RelayState", logoutRequest::getParameter, uriBuilder); + addParameter("SigAlg", logoutRequest::getParameter, uriBuilder); + addParameter("Signature", logoutRequest::getParameter, uriBuilder); + this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString()); + } + + private void addParameter(String name, Function parameters, UriComponentsBuilder builder) { + Assert.hasText(name, "name cannot be empty or null"); + if (StringUtils.hasText(parameters.apply(name))) { + builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1), + UriUtils.encode(parameters.apply(name), StandardCharsets.ISO_8859_1)); + } + } + + private void doPost(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); + } + + private String createSamlPostRequestFormData(String location, String saml, String relayState) { + StringBuilder html = new StringBuilder(); + html.append("\n"); + html.append("\n").append(" \n"); + html.append(" \n"); + html.append(" \n"); + html.append(" \n"); + html.append("

\n"); + html.append(" Note: Since your browser does not support JavaScript,\n"); + html.append(" you must press the Continue button once to proceed.\n"); + html.append("

\n"); + html.append(" \n"); + html.append(" \n"); + html.append("
\n"); + html.append("
\n"); + html.append(" \n"); + if (StringUtils.hasText(relayState)) { + html.append(" \n"); + } + html.append("
\n"); + html.append(" \n"); + html.append("
\n"); + html.append(" \n"); + html.append(" \n"); + html.append(""); + return html.toString(); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java new file mode 100644 index 00000000000..fc0c71aad85 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java @@ -0,0 +1,76 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterOutputStream; + +import org.springframework.security.saml2.Saml2Exception; + +/** + * Utility methods for working with serialized SAML messages. + * + * For internal use only. + * + * @author Josh Cummings + */ +final class Saml2Utils { + + private Saml2Utils() { + } + + static String samlEncode(byte[] b) { + return Base64.getEncoder().encodeToString(b); + } + + static byte[] samlDecode(String s) { + return Base64.getDecoder().decode(s); + } + + static byte[] samlDeflate(String s) { + try { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + DeflaterOutputStream deflater = new DeflaterOutputStream(b, new Deflater(Deflater.DEFLATED, true)); + deflater.write(s.getBytes(StandardCharsets.UTF_8)); + deflater.finish(); + return b.toByteArray(); + } + catch (IOException ex) { + throw new Saml2Exception("Unable to deflate string", ex); + } + } + + static String samlInflate(byte[] b) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InflaterOutputStream iout = new InflaterOutputStream(out, new Inflater(true)); + iout.write(b); + iout.finish(); + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + catch (IOException ex) { + throw new Saml2Exception("Unable to inflate string", ex); + } + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java b/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java new file mode 100644 index 00000000000..7469c0a778f --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java @@ -0,0 +1,125 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.time.Clock; +import java.util.function.Consumer; + +import javax.servlet.http.HttpServletRequest; + +import org.joda.time.DateTime; +import org.opensaml.saml.saml2.core.LogoutRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with + * OpenSAML 3 + * + * @author Josh Cummings + * @since 5.6 + * @deprecated Because OpenSAML 3 has reached End-of-Life, please update to + * {@code OpenSaml4LogoutRequestResolver} + */ +public final class OpenSaml3LogoutRequestResolver implements Saml2LogoutRequestResolver { + + private final OpenSamlLogoutRequestResolver logoutRequestResolver; + + private Consumer parametersConsumer = (parameters) -> { + }; + + private Clock clock = Clock.systemUTC(); + + /** + * Construct a {@link OpenSaml3LogoutRequestResolver} + */ + public OpenSaml3LogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.logoutRequestResolver = new OpenSamlLogoutRequestResolver(relyingPartyRegistrationResolver); + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication) { + return this.logoutRequestResolver.resolve(request, authentication, (registration, logoutRequest) -> { + logoutRequest.setIssueInstant(new DateTime(this.clock.millis())); + this.parametersConsumer + .accept(new LogoutRequestParameters(request, registration, authentication, logoutRequest)); + }); + } + + /** + * Set a {@link Consumer} for modifying the OpenSAML {@link LogoutRequest} + * @param parametersConsumer a consumer that accepts an + * {@link LogoutRequestParameters} + */ + public void setParametersConsumer(Consumer parametersConsumer) { + Assert.notNull(parametersConsumer, "parametersConsumer cannot be null"); + this.parametersConsumer = parametersConsumer; + } + + /** + * Use this {@link Clock} for generating the issued {@link DateTime} + * @param clock the {@link Clock} to use + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock must not be null"); + this.clock = clock; + } + + public static final class LogoutRequestParameters { + + private final HttpServletRequest request; + + private final RelyingPartyRegistration registration; + + private final Authentication authentication; + + private final LogoutRequest logoutRequest; + + public LogoutRequestParameters(HttpServletRequest request, RelyingPartyRegistration registration, + Authentication authentication, LogoutRequest logoutRequest) { + this.request = request; + this.registration = registration; + this.authentication = authentication; + this.logoutRequest = logoutRequest; + } + + public HttpServletRequest getRequest() { + return this.request; + } + + public RelyingPartyRegistration getRelyingPartyRegistration() { + return this.registration; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + public LogoutRequest getLogoutRequest() { + return this.logoutRequest; + } + + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java b/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java new file mode 100644 index 00000000000..aded12b4286 --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml3Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java @@ -0,0 +1,121 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.time.Clock; +import java.util.function.Consumer; + +import javax.servlet.http.HttpServletRequest; + +import org.joda.time.DateTime; +import org.opensaml.saml.saml2.core.LogoutResponse; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutResponseResolver} for resolving SAML 2.0 Logout Responses with + * OpenSAML 3 + * + * @author Josh Cummings + * @since 5.6 + * @deprecated Because OpenSAML 3 has reached End-of-Life, please update to + * {@code OpenSaml4LogoutResponseResolver} + */ +public final class OpenSaml3LogoutResponseResolver implements Saml2LogoutResponseResolver { + + private final OpenSamlLogoutResponseResolver logoutResponseResolver; + + private Consumer parametersConsumer = (parameters) -> { + }; + + private Clock clock = Clock.systemUTC(); + + /** + * Construct a {@link OpenSaml3LogoutResponseResolver} + */ + public OpenSaml3LogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.logoutResponseResolver = new OpenSamlLogoutResponseResolver(relyingPartyRegistrationResolver); + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication) { + return this.logoutResponseResolver.resolve(request, authentication, (registration, logoutResponse) -> { + logoutResponse.setIssueInstant(new DateTime(this.clock.millis())); + this.parametersConsumer + .accept(new LogoutResponseParameters(request, registration, authentication, logoutResponse)); + }); + } + + public void setClock(Clock clock) { + Assert.notNull(clock, "clock must not be null"); + this.clock = clock; + } + + /** + * Set a {@link Consumer} for modifying the OpenSAML {@link LogoutResponse} + * @param parametersConsumer a consumer that accepts an + * {@link LogoutResponseParameters} + */ + public void setParametersConsumer(Consumer parametersConsumer) { + Assert.notNull(parametersConsumer, "parametersConsumer cannot be null"); + this.parametersConsumer = parametersConsumer; + } + + public static final class LogoutResponseParameters { + + private final HttpServletRequest request; + + private final RelyingPartyRegistration registration; + + private final Authentication authentication; + + private final LogoutResponse logoutResponse; + + public LogoutResponseParameters(HttpServletRequest request, RelyingPartyRegistration registration, + Authentication authentication, LogoutResponse logoutResponse) { + this.request = request; + this.registration = registration; + this.authentication = authentication; + this.logoutResponse = logoutResponse; + } + + public HttpServletRequest getRequest() { + return this.request; + } + + public RelyingPartyRegistration getRelyingPartyRegistration() { + return this.registration; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + public LogoutResponse getLogoutResponse() { + return this.logoutResponse; + } + + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolverTests.java b/saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolverTests.java new file mode 100644 index 00000000000..99e5d225b1a --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolverTests.java @@ -0,0 +1,65 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OpenSaml3LogoutRequestResolver} + */ +public class OpenSaml3LogoutRequestResolverTests { + + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class); + + @Test + public void resolveWhenCustomParametersConsumerThenUses() { + OpenSaml3LogoutRequestResolver logoutRequestResolver = new OpenSaml3LogoutRequestResolver( + this.relyingPartyRegistrationResolver); + logoutRequestResolver.setParametersConsumer((parameters) -> parameters.getLogoutRequest().setID("myid")); + HttpServletRequest request = new MockHttpServletRequest(); + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build(); + Authentication authentication = new TestingAuthenticationToken("user", "password"); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + Saml2LogoutRequest logoutRequest = logoutRequestResolver.resolve(request, authentication); + assertThat(logoutRequest.getId()).isEqualTo("myid"); + } + + @Test + public void setParametersConsumerWhenNullThenIllegalArgument() { + OpenSaml3LogoutRequestResolver logoutRequestResolver = new OpenSaml3LogoutRequestResolver( + this.relyingPartyRegistrationResolver); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null)); + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolverTests.java b/saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolverTests.java new file mode 100644 index 00000000000..89d0bc6a5e0 --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml3Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolverTests.java @@ -0,0 +1,74 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.opensaml.saml.saml2.core.LogoutRequest; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml3LogoutResponseResolver.LogoutResponseParameters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link OpenSaml3LogoutResponseResolver} + */ +public class OpenSaml3LogoutResponseResolverTests { + + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class); + + @Test + public void resolveWhenCustomParametersConsumerThenUses() { + OpenSaml3LogoutResponseResolver logoutResponseResolver = new OpenSaml3LogoutResponseResolver( + this.relyingPartyRegistrationResolver); + Consumer parametersConsumer = mock(Consumer.class); + logoutResponseResolver.setParametersConsumer(parametersConsumer); + MockHttpServletRequest request = new MockHttpServletRequest(); + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build(); + Authentication authentication = new TestingAuthenticationToken("user", "password"); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + request.setParameter("SAMLRequest", + Saml2Utils.samlEncode(OpenSamlSigningUtils.serialize(logoutRequest).getBytes())); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + Saml2LogoutResponse logoutResponse = logoutResponseResolver.resolve(request, authentication); + assertThat(logoutResponse).isNotNull(); + verify(parametersConsumer).accept(any()); + } + + @Test + public void setParametersConsumerWhenNullThenIllegalArgument() { + OpenSaml3LogoutRequestResolver logoutRequestResolver = new OpenSaml3LogoutRequestResolver( + this.relyingPartyRegistrationResolver); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null)); + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java new file mode 100644 index 00000000000..13409e4cdb1 --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java @@ -0,0 +1,123 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.time.Clock; +import java.time.Instant; +import java.util.function.Consumer; + +import javax.servlet.http.HttpServletRequest; + +import org.opensaml.saml.saml2.core.LogoutRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with + * OpenSAML 4 + * + * @author Josh Cummings + * @since 5.6 + */ +public final class OpenSaml4LogoutRequestResolver implements Saml2LogoutRequestResolver { + + private final OpenSamlLogoutRequestResolver logoutRequestResolver; + + private Consumer parametersConsumer = (parameters) -> { + }; + + private Clock clock = Clock.systemUTC(); + + /** + * Construct a {@link OpenSaml4LogoutRequestResolver} + */ + public OpenSaml4LogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.logoutRequestResolver = new OpenSamlLogoutRequestResolver(relyingPartyRegistrationResolver); + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication) { + return this.logoutRequestResolver.resolve(request, authentication, (registration, logoutRequest) -> { + logoutRequest.setIssueInstant(Instant.now(this.clock)); + this.parametersConsumer + .accept(new LogoutRequestParameters(request, registration, authentication, logoutRequest)); + }); + } + + /** + * Set a {@link Consumer} for modifying the OpenSAML {@link LogoutRequest} + * @param parametersConsumer a consumer that accepts an + * {@link LogoutRequestParameters} + */ + public void setParametersConsumer(Consumer parametersConsumer) { + Assert.notNull(parametersConsumer, "parametersConsumer cannot be null"); + this.parametersConsumer = parametersConsumer; + } + + /** + * Use this {@link Clock} for determining the issued {@link Instant} + * @param clock the {@link Clock} to use + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock must not be null"); + this.clock = clock; + } + + public static final class LogoutRequestParameters { + + private final HttpServletRequest request; + + private final RelyingPartyRegistration registration; + + private final Authentication authentication; + + private final LogoutRequest logoutRequest; + + public LogoutRequestParameters(HttpServletRequest request, RelyingPartyRegistration registration, + Authentication authentication, LogoutRequest logoutRequest) { + this.request = request; + this.registration = registration; + this.authentication = authentication; + this.logoutRequest = logoutRequest; + } + + public HttpServletRequest getRequest() { + return this.request; + } + + public RelyingPartyRegistration getRelyingPartyRegistration() { + return this.registration; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + public LogoutRequest getLogoutRequest() { + return this.logoutRequest; + } + + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java new file mode 100644 index 00000000000..e90b2b177c3 --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java @@ -0,0 +1,119 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.time.Clock; +import java.time.Instant; +import java.util.function.Consumer; + +import javax.servlet.http.HttpServletRequest; + +import org.opensaml.saml.saml2.core.LogoutResponse; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutResponseResolver} for resolving SAML 2.0 Logout Responses with + * OpenSAML 4 + * + * @author Josh Cummings + * @since 5.6 + */ +public final class OpenSaml4LogoutResponseResolver implements Saml2LogoutResponseResolver { + + private final OpenSamlLogoutResponseResolver logoutResponseResolver; + + private Consumer parametersConsumer = (parameters) -> { + }; + + private Clock clock = Clock.systemUTC(); + + /** + * Construct a {@link OpenSaml4LogoutResponseResolver} + */ + public OpenSaml4LogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.logoutResponseResolver = new OpenSamlLogoutResponseResolver(relyingPartyRegistrationResolver); + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication) { + return this.logoutResponseResolver.resolve(request, authentication, (registration, logoutResponse) -> { + logoutResponse.setIssueInstant(Instant.now(this.clock)); + this.parametersConsumer + .accept(new LogoutResponseParameters(request, registration, authentication, logoutResponse)); + }); + } + + /** + * Set a {@link Consumer} for modifying the OpenSAML {@link LogoutResponse} + * @param parametersConsumer a consumer that accepts an + * {@link LogoutResponseParameters} + */ + public void setParametersConsumer(Consumer parametersConsumer) { + Assert.notNull(parametersConsumer, "parametersConsumer cannot be null"); + this.parametersConsumer = parametersConsumer; + } + + public void setClock(Clock clock) { + Assert.notNull(clock, "clock must not be null"); + this.clock = clock; + } + + public static final class LogoutResponseParameters { + + private final HttpServletRequest request; + + private final RelyingPartyRegistration registration; + + private final Authentication authentication; + + private final LogoutResponse logoutResponse; + + public LogoutResponseParameters(HttpServletRequest request, RelyingPartyRegistration registration, + Authentication authentication, LogoutResponse logoutResponse) { + this.request = request; + this.registration = registration; + this.authentication = authentication; + this.logoutResponse = logoutResponse; + } + + public HttpServletRequest getRequest() { + return this.request; + } + + public RelyingPartyRegistration getRelyingPartyRegistration() { + return this.registration; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + public LogoutResponse getLogoutResponse() { + return this.logoutResponse; + } + + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolverTests.java b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolverTests.java new file mode 100644 index 00000000000..6ea35b47161 --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolverTests.java @@ -0,0 +1,65 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OpenSaml4LogoutRequestResolver} + */ +public class OpenSaml4LogoutRequestResolverTests { + + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class); + + @Test + public void resolveWhenCustomParametersConsumerThenUses() { + OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver( + this.relyingPartyRegistrationResolver); + logoutRequestResolver.setParametersConsumer((parameters) -> parameters.getLogoutRequest().setID("myid")); + HttpServletRequest request = new MockHttpServletRequest(); + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build(); + Authentication authentication = new TestingAuthenticationToken("user", "password"); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + Saml2LogoutRequest logoutRequest = logoutRequestResolver.resolve(request, authentication); + assertThat(logoutRequest.getId()).isEqualTo("myid"); + } + + @Test + public void setParametersConsumerWhenNullThenIllegalArgument() { + OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver( + this.relyingPartyRegistrationResolver); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null)); + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolverTests.java b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolverTests.java new file mode 100644 index 00000000000..fd1b21c3ec0 --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml4Test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolverTests.java @@ -0,0 +1,74 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.opensaml.saml.saml2.core.LogoutRequest; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutResponseResolver.LogoutResponseParameters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link OpenSaml4LogoutResponseResolver} + */ +public class OpenSaml4LogoutResponseResolverTests { + + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class); + + @Test + public void resolveWhenCustomParametersConsumerThenUses() { + OpenSaml4LogoutResponseResolver logoutResponseResolver = new OpenSaml4LogoutResponseResolver( + this.relyingPartyRegistrationResolver); + Consumer parametersConsumer = mock(Consumer.class); + logoutResponseResolver.setParametersConsumer(parametersConsumer); + MockHttpServletRequest request = new MockHttpServletRequest(); + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build(); + Authentication authentication = new TestingAuthenticationToken("user", "password"); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + request.setParameter("SAMLRequest", + Saml2Utils.samlEncode(OpenSamlSigningUtils.serialize(logoutRequest).getBytes())); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + Saml2LogoutResponse logoutResponse = logoutResponseResolver.resolve(request, authentication); + assertThat(logoutResponse).isNotNull(); + verify(parametersConsumer).accept(any()); + } + + @Test + public void setParametersConsumerWhenNullThenIllegalArgument() { + OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver( + this.relyingPartyRegistrationResolver); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null)); + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java index d7a1fcdc34e..7acb769f4ab 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java @@ -54,6 +54,8 @@ import org.opensaml.saml.saml2.core.EncryptedAttribute; import org.opensaml.saml.saml2.core.EncryptedID; import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.LogoutResponse; import org.opensaml.saml.saml2.core.NameID; import org.opensaml.saml.saml2.core.Response; import org.opensaml.saml.saml2.core.Status; @@ -63,6 +65,10 @@ import org.opensaml.saml.saml2.core.SubjectConfirmationData; import org.opensaml.saml.saml2.core.impl.AttributeBuilder; import org.opensaml.saml.saml2.core.impl.AttributeStatementBuilder; +import org.opensaml.saml.saml2.core.impl.IssuerBuilder; +import org.opensaml.saml.saml2.core.impl.LogoutRequestBuilder; +import org.opensaml.saml.saml2.core.impl.LogoutResponseBuilder; +import org.opensaml.saml.saml2.core.impl.NameIDBuilder; import org.opensaml.saml.saml2.core.impl.StatusBuilder; import org.opensaml.saml.saml2.core.impl.StatusCodeBuilder; import org.opensaml.saml.saml2.encryption.Encrypter; @@ -83,6 +89,7 @@ import org.springframework.security.saml2.core.OpenSamlInitializationService; import org.springframework.security.saml2.core.Saml2X509Credential; import org.springframework.security.saml2.core.TestSaml2X509Credentials; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; public final class TestOpenSamlObjects { @@ -93,7 +100,7 @@ public final class TestOpenSamlObjects { private static String DESTINATION = "https://localhost/login/saml2/sso/idp-alias"; - private static String RELYING_PARTY_ENTITY_ID = "https://localhost/saml2/service-provider-metadata/idp-alias"; + public static String RELYING_PARTY_ENTITY_ID = "https://localhost/saml2/service-provider-metadata/idp-alias"; private static String ASSERTING_PARTY_ENTITY_ID = "https://some.idp.test/saml2/idp"; @@ -221,7 +228,7 @@ static T signed(T signable, Saml2X509Credential c return signable; } - static T signed(T signable, Saml2X509Credential credential, String entityId) { + public static T signed(T signable, Saml2X509Credential credential, String entityId) { return signed(signable, credential, entityId, SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256); } @@ -342,6 +349,41 @@ static Status status(String code) { return status; } + public static LogoutRequest assertingPartyLogoutRequest(RelyingPartyRegistration registration) { + LogoutRequestBuilder logoutRequestBuilder = new LogoutRequestBuilder(); + LogoutRequest logoutRequest = logoutRequestBuilder.buildObject(); + logoutRequest.setID("id"); + NameIDBuilder nameIdBuilder = new NameIDBuilder(); + NameID nameId = nameIdBuilder.buildObject(); + nameId.setValue("user"); + logoutRequest.setNameID(nameId); + IssuerBuilder issuerBuilder = new IssuerBuilder(); + Issuer issuer = issuerBuilder.buildObject(); + issuer.setValue(registration.getAssertingPartyDetails().getEntityId()); + logoutRequest.setIssuer(issuer); + logoutRequest.setDestination(registration.getSingleLogoutServiceLocation()); + return logoutRequest; + } + + public static LogoutResponse assertingPartyLogoutResponse(RelyingPartyRegistration registration) { + LogoutResponseBuilder logoutResponseBuilder = new LogoutResponseBuilder(); + LogoutResponse logoutResponse = logoutResponseBuilder.buildObject(); + logoutResponse.setID("id"); + StatusBuilder statusBuilder = new StatusBuilder(); + StatusCodeBuilder statusCodeBuilder = new StatusCodeBuilder(); + StatusCode code = statusCodeBuilder.buildObject(); + code.setValue(StatusCode.SUCCESS); + Status status = statusBuilder.buildObject(); + status.setStatusCode(code); + logoutResponse.setStatus(status); + IssuerBuilder issuerBuilder = new IssuerBuilder(); + Issuer issuer = issuerBuilder.buildObject(); + issuer.setValue(registration.getAssertingPartyDetails().getEntityId()); + logoutResponse.setIssuer(issuer); + logoutResponse.setDestination(registration.getSingleLogoutServiceResponseLocation()); + return logoutResponse; + } + static T build(QName qName) { return (T) XMLObjectProviderRegistrySupport.getBuilderFactory().getBuilder(qName).buildObject(qName); } diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java new file mode 100644 index 00000000000..0a032993367 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutRequestValidatorTests.java @@ -0,0 +1,173 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +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.Saml2ErrorCodes; +import org.springframework.security.saml2.core.TestSaml2X509Credentials; +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.authentication.TestOpenSamlObjects; +import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OpenSamlLogoutRequestValidator} + * + * @author Josh Cummings + */ +public class OpenSamlLogoutRequestValidatorTests { + + private final OpenSamlLogoutRequestValidator manager = new OpenSamlLogoutRequestValidator(); + + @Test + public void handleWhenPostBindingThenValidates() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + sign(logoutRequest, registration); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); + } + + @Test + public void handleWhenRedirectBindingThenValidatesSignatureParameter() { + RelyingPartyRegistration registration = registration() + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.REDIRECT)) + .build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + Saml2LogoutRequest request = redirect(logoutRequest, registration, OpenSamlSigningUtils.sign(registration)); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isFalse(); + } + + @Test + public void handleWhenInvalidIssuerThenInvalidSignatureError() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.getIssuer().setValue("wrong"); + sign(logoutRequest, registration); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenMismatchedUserThenInvalidRequestError() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.getNameID().setValue("wrong"); + sign(logoutRequest, registration); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_REQUEST); + } + + @Test + public void handleWhenMissingUserThenSubjectNotFoundError() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.setNameID(null); + sign(logoutRequest, registration); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.SUBJECT_NOT_FOUND); + } + + @Test + public void handleWhenMismatchedDestinationThenInvalidDestinationError() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.setDestination("wrong"); + sign(logoutRequest, registration); + Saml2LogoutRequest request = post(logoutRequest, registration); + Saml2LogoutRequestValidatorParameters parameters = new Saml2LogoutRequestValidatorParameters(request, + registration, authentication(registration)); + Saml2LogoutValidatorResult result = this.manager.validate(parameters); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_DESTINATION); + } + + private RelyingPartyRegistration.Builder registration() { + return signing(verifying(TestRelyingPartyRegistrations.noCredentials())) + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)); + } + + private RelyingPartyRegistration.Builder verifying(RelyingPartyRegistration.Builder builder) { + return builder.assertingPartyDetails((party) -> party + .verificationX509Credentials((c) -> c.add(TestSaml2X509Credentials.relyingPartyVerifyingCredential()))); + } + + private RelyingPartyRegistration.Builder signing(RelyingPartyRegistration.Builder builder) { + return builder.signingX509Credentials((c) -> c.add(TestSaml2X509Credentials.assertingPartySigningCredential())); + } + + private Authentication authentication(RelyingPartyRegistration registration) { + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()); + principal.setRelyingPartyRegistrationId(registration.getRegistrationId()); + return new Saml2Authentication(principal, "response", new ArrayList<>()); + } + + private Saml2LogoutRequest post(LogoutRequest logoutRequest, RelyingPartyRegistration registration) { + return Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest(Saml2Utils.samlEncode(serialize(logoutRequest).getBytes(StandardCharsets.UTF_8))).build(); + } + + private Saml2LogoutRequest redirect(LogoutRequest logoutRequest, RelyingPartyRegistration registration, + QueryParametersPartial partial) { + String serialized = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(serialize(logoutRequest))); + Map parameters = partial.param("SAMLRequest", serialized).parameters(); + return Saml2LogoutRequest.withRelyingPartyRegistration(registration).samlRequest(serialized) + .parameters((params) -> params.putAll(parameters)).build(); + } + + private void sign(LogoutRequest logoutRequest, RelyingPartyRegistration registration) { + TestOpenSamlObjects.signed(logoutRequest, registration.getSigningX509Credentials().iterator().next(), + registration.getAssertingPartyDetails().getEntityId()); + } + + private String serialize(XMLObject object) { + return OpenSamlSigningUtils.serialize(object); + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java new file mode 100644 index 00000000000..7c2e8d1f285 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlLogoutResponseValidatorTests.java @@ -0,0 +1,158 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.junit.jupiter.api.Test; +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.Saml2ErrorCodes; +import org.springframework.security.saml2.core.TestSaml2X509Credentials; +import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects; +import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OpenSamlLogoutResponseValidator} + * + * @author Josh Cummings + */ +public class OpenSamlLogoutResponseValidatorTests { + + private final OpenSamlLogoutResponseValidator manager = new OpenSamlLogoutResponseValidator(); + + @Test + public void handleWhenAuthenticatedThenHandles() { + RelyingPartyRegistration registration = signing(verifying(registration())).build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + sign(logoutResponse, registration); + Saml2LogoutResponse response = post(logoutResponse, registration); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, + logoutRequest, registration); + this.manager.validate(parameters); + } + + @Test + public void handleWhenRedirectBindingThenValidatesSignatureParameter() { + RelyingPartyRegistration registration = signing(verifying(registration())) + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.REDIRECT)) + .build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + Saml2LogoutResponse response = redirect(logoutResponse, registration, OpenSamlSigningUtils.sign(registration)); + Saml2LogoutResponseValidatorParameters parameters = new Saml2LogoutResponseValidatorParameters(response, + logoutRequest, registration); + this.manager.validate(parameters); + } + + @Test + public void handleWhenInvalidIssuerThenInvalidSignatureError() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.getIssuer().setValue("wrong"); + sign(logoutResponse, registration); + 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().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenMismatchedDestinationThenInvalidDestinationError() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.setDestination("wrong"); + sign(logoutResponse, registration); + 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().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_DESTINATION); + } + + @Test + public void handleWhenStatusNotSuccessThenInvalidResponseError() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL); + sign(logoutResponse, registration); + 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().iterator().next().getErrorCode()).isEqualTo(Saml2ErrorCodes.INVALID_RESPONSE); + } + + private RelyingPartyRegistration.Builder registration() { + return signing(verifying(TestRelyingPartyRegistrations.noCredentials())) + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)); + } + + private RelyingPartyRegistration.Builder verifying(RelyingPartyRegistration.Builder builder) { + return builder.assertingPartyDetails((party) -> party + .verificationX509Credentials((c) -> c.add(TestSaml2X509Credentials.relyingPartyVerifyingCredential()))); + } + + private RelyingPartyRegistration.Builder signing(RelyingPartyRegistration.Builder builder) { + return builder.signingX509Credentials((c) -> c.add(TestSaml2X509Credentials.assertingPartySigningCredential())); + } + + private Saml2LogoutResponse post(LogoutResponse logoutResponse, RelyingPartyRegistration registration) { + return Saml2LogoutResponse.withRelyingPartyRegistration(registration) + .samlResponse(Saml2Utils.samlEncode(serialize(logoutResponse).getBytes(StandardCharsets.UTF_8))) + .build(); + } + + private Saml2LogoutResponse redirect(LogoutResponse logoutResponse, RelyingPartyRegistration registration, + QueryParametersPartial partial) { + String serialized = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(serialize(logoutResponse))); + Map parameters = partial.param("SAMLResponse", serialized).parameters(); + return Saml2LogoutResponse.withRelyingPartyRegistration(registration).samlResponse(serialized) + .parameters((params) -> params.putAll(parameters)).build(); + } + + private void sign(LogoutResponse logoutResponse, RelyingPartyRegistration registration) { + TestOpenSamlObjects.signed(logoutResponse, registration.getSigningX509Credentials().iterator().next(), + registration.getAssertingPartyDetails().getEntityId()); + } + + private String serialize(XMLObject object) { + return OpenSamlSigningUtils.serialize(object); + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlSigningUtils.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlSigningUtils.java new file mode 100644 index 00000000000..ba6481badb7 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/logout/OpenSamlSigningUtils.java @@ -0,0 +1,173 @@ +/* + * 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.saml2.provider.service.authentication.logout; + +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.shibboleth.utilities.java.support.resolver.CriteriaSet; +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.core.xml.io.Marshaller; +import org.opensaml.core.xml.io.MarshallingException; +import org.opensaml.saml.security.impl.SAMLMetadataSignatureSigningParametersResolver; +import org.opensaml.security.SecurityException; +import org.opensaml.security.credential.BasicCredential; +import org.opensaml.security.credential.Credential; +import org.opensaml.security.credential.CredentialSupport; +import org.opensaml.security.credential.UsageType; +import org.opensaml.xmlsec.SignatureSigningParameters; +import org.opensaml.xmlsec.SignatureSigningParametersResolver; +import org.opensaml.xmlsec.criterion.SignatureSigningConfigurationCriterion; +import org.opensaml.xmlsec.crypto.XMLSigningUtil; +import org.opensaml.xmlsec.impl.BasicSignatureSigningConfiguration; +import org.opensaml.xmlsec.signature.SignableXMLObject; +import org.opensaml.xmlsec.signature.support.SignatureConstants; +import org.opensaml.xmlsec.signature.support.SignatureSupport; +import org.w3c.dom.Element; + +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +/** + * Utility methods for signing SAML components with OpenSAML + * + * For internal use only. + * + * @author Josh Cummings + */ +final class OpenSamlSigningUtils { + + static String serialize(XMLObject object) { + try { + Marshaller marshaller = XMLObjectProviderRegistrySupport.getMarshallerFactory().getMarshaller(object); + Element element = marshaller.marshall(object); + return SerializeSupport.nodeToString(element); + } + catch (MarshallingException ex) { + throw new Saml2Exception(ex); + } + } + + static O sign(O object, RelyingPartyRegistration relyingPartyRegistration) { + SignatureSigningParameters parameters = resolveSigningParameters(relyingPartyRegistration); + try { + SignatureSupport.signObject(object, parameters); + return object; + } + catch (Exception ex) { + throw new Saml2Exception(ex); + } + } + + static QueryParametersPartial sign(RelyingPartyRegistration registration) { + return new QueryParametersPartial(registration); + } + + private static SignatureSigningParameters resolveSigningParameters( + RelyingPartyRegistration relyingPartyRegistration) { + List credentials = resolveSigningCredentials(relyingPartyRegistration); + List algorithms = relyingPartyRegistration.getAssertingPartyDetails().getSigningAlgorithms(); + List digests = Collections.singletonList(SignatureConstants.ALGO_ID_DIGEST_SHA256); + String canonicalization = SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS; + SignatureSigningParametersResolver resolver = new SAMLMetadataSignatureSigningParametersResolver(); + CriteriaSet criteria = new CriteriaSet(); + BasicSignatureSigningConfiguration signingConfiguration = new BasicSignatureSigningConfiguration(); + signingConfiguration.setSigningCredentials(credentials); + signingConfiguration.setSignatureAlgorithms(algorithms); + signingConfiguration.setSignatureReferenceDigestMethods(digests); + signingConfiguration.setSignatureCanonicalizationAlgorithm(canonicalization); + criteria.add(new SignatureSigningConfigurationCriterion(signingConfiguration)); + try { + SignatureSigningParameters parameters = resolver.resolveSingle(criteria); + Assert.notNull(parameters, "Failed to resolve any signing credential"); + return parameters; + } + catch (Exception ex) { + throw new Saml2Exception(ex); + } + } + + private static List resolveSigningCredentials(RelyingPartyRegistration relyingPartyRegistration) { + List credentials = new ArrayList<>(); + for (Saml2X509Credential x509Credential : relyingPartyRegistration.getSigningX509Credentials()) { + X509Certificate certificate = x509Credential.getCertificate(); + PrivateKey privateKey = x509Credential.getPrivateKey(); + BasicCredential credential = CredentialSupport.getSimpleCredential(certificate, privateKey); + credential.setEntityId(relyingPartyRegistration.getEntityId()); + credential.setUsageType(UsageType.SIGNING); + credentials.add(credential); + } + return credentials; + } + + static class QueryParametersPartial { + + final RelyingPartyRegistration registration; + + final Map components = new LinkedHashMap<>(); + + QueryParametersPartial(RelyingPartyRegistration registration) { + this.registration = registration; + } + + QueryParametersPartial param(String key, String value) { + this.components.put(key, value); + return this; + } + + Map parameters() { + SignatureSigningParameters parameters = resolveSigningParameters(this.registration); + Credential credential = parameters.getSigningCredential(); + String algorithmUri = parameters.getSignatureAlgorithm(); + this.components.put("SigAlg", algorithmUri); + UriComponentsBuilder builder = UriComponentsBuilder.newInstance(); + for (Map.Entry component : this.components.entrySet()) { + builder.queryParam(component.getKey(), + UriUtils.encode(component.getValue(), StandardCharsets.ISO_8859_1)); + } + String queryString = builder.build(true).toString().substring(1); + try { + byte[] rawSignature = XMLSigningUtil.signWithURI(credential, algorithmUri, + queryString.getBytes(StandardCharsets.UTF_8)); + String b64Signature = Saml2Utils.samlEncode(rawSignature); + this.components.put("Signature", b64Signature); + } + catch (SecurityException ex) { + throw new Saml2Exception(ex); + } + return this.components; + } + + } + + private OpenSamlSigningUtils() { + + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java index cef7516c4b9..d42fc875be6 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java @@ -41,7 +41,8 @@ public void resolveWhenRelyingPartyThenMetadataMatches() { .contains("") .contains("MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBh") .contains("Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\"") - .contains("Location=\"https://rp.example.org/acs\" index=\"1\""); + .contains("Location=\"https://rp.example.org/acs\" index=\"1\"") + .contains("ResponseLocation=\"https://rp.example.org/logout/saml2/response\""); } @Test @@ -56,7 +57,8 @@ public void resolveWhenRelyingPartyNoCredentialsThenMetadataMatches() { .contains("WantAssertionsSigned=\"true\"").doesNotContain("") .doesNotContain("") .contains("Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"") - .contains("Location=\"https://rp.example.org/acs\" index=\"1\""); + .contains("Location=\"https://rp.example.org/acs\" index=\"1\"") + .contains("ResponseLocation=\"https://rp.example.org/logout/saml2/response\""); } } diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java index 7d105aecf3a..c5626821fc7 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java @@ -37,17 +37,23 @@ public static RelyingPartyRegistration.Builder relyingPartyRegistration() { String apEntityId = "https://simplesaml-for-spring-saml.apps.pcfone.io/saml2/idp/metadata.php"; Saml2X509Credential verificationCertificate = TestSaml2X509Credentials.relyingPartyVerifyingCredential(); String singleSignOnServiceLocation = "https://simplesaml-for-spring-saml.apps.pcfone.io/saml2/idp/SSOService.php"; + String singleLogoutServiceLocation = "{baseUrl}/logout/saml2/slo"; return RelyingPartyRegistration.withRegistrationId(registrationId).entityId(rpEntityId) .assertionConsumerServiceLocation(assertionConsumerServiceLocation) - .credentials((c) -> c.add(signingCredential)) + .singleLogoutServiceLocation(singleLogoutServiceLocation).credentials((c) -> c.add(signingCredential)) .providerDetails((c) -> c.entityId(apEntityId).webSsoUrl(singleSignOnServiceLocation)) .credentials((c) -> c.add(verificationCertificate)); } public static RelyingPartyRegistration.Builder noCredentials() { return RelyingPartyRegistration.withRegistrationId("registration-id").entityId("rp-entity-id") - .assertionConsumerServiceLocation("https://rp.example.org/acs").assertingPartyDetails((party) -> party - .entityId("ap-entity-id").singleSignOnServiceLocation("https://ap.example.org/sso")); + .singleLogoutServiceLocation("https://rp.example.org/logout/saml2/request") + .singleLogoutServiceResponseLocation("https://rp.example.org/logout/saml2/response") + .assertionConsumerServiceLocation("https://rp.example.org/acs") + .assertingPartyDetails((party) -> party.entityId("ap-entity-id") + .singleSignOnServiceLocation("https://ap.example.org/sso") + .singleLogoutServiceLocation("https://ap.example.org/logout/saml2/request") + .singleLogoutServiceResponseLocation("https://ap.example.org/logout/saml2/response")); } public static RelyingPartyRegistration.Builder full() { diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java index 3b786cdef0a..753f4f62c42 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java @@ -52,6 +52,9 @@ public void resolveWhenRequestContainsRegistrationIdThenResolves() { .isEqualTo("http://localhost/saml2/service-provider-metadata/" + this.registration.getRegistrationId()); assertThat(registration.getAssertionConsumerServiceLocation()) .isEqualTo("http://localhost/login/saml2/sso/" + this.registration.getRegistrationId()); + assertThat(registration.getSingleLogoutServiceLocation()).isEqualTo("http://localhost/logout/saml2/slo"); + assertThat(registration.getSingleLogoutServiceResponseLocation()) + .isEqualTo("http://localhost/logout/saml2/slo"); } @Test diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java new file mode 100644 index 00000000000..e051edf2283 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java @@ -0,0 +1,229 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link HttpSessionLogoutRequestRepository} + */ +public class HttpSessionLogoutRequestRepositoryTests { + + HttpSessionLogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository(); + + @Test + public void loadLogoutRequestWhenHttpServletRequestIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.logoutRequestRepository.loadLogoutRequest(null)); + } + + @Test + public void loadLogoutRequestWhenNotSavedThenReturnNull() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter("RelayState", "state-1234"); + Saml2LogoutRequest logoutRequest = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(logoutRequest).isNull(); + } + + @Test + public void loadLogoutRequestWhenSavedThenReturnLogoutRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response); + request.addParameter("RelayState", logoutRequest.getRelayState()); + Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(loadedLogoutRequest).isEqualTo(logoutRequest); + } + + @Test + public void loadLogoutRequestWhenMultipleSavedThenReplacesLogoutRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Saml2LogoutRequest one = createLogoutRequest().relayState("state-1122").build(); + this.logoutRequestRepository.saveLogoutRequest(one, request, response); + Saml2LogoutRequest two = createLogoutRequest().relayState("state-3344").build(); + this.logoutRequestRepository.saveLogoutRequest(two, request, response); + request.setParameter("RelayState", one.getRelayState()); + assertThat(this.logoutRequestRepository.loadLogoutRequest(request)).isNull(); + request.setParameter("RelayState", two.getRelayState()); + assertThat(this.logoutRequestRepository.loadLogoutRequest(request)).isEqualTo(two); + } + + @Test + public void loadLogoutRequestWhenSavedAndStateParameterNullThenReturnNull() { + MockHttpServletRequest request = new MockHttpServletRequest(); + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, new MockHttpServletResponse()); + assertThat(this.logoutRequestRepository.loadLogoutRequest(request)).isNull(); + } + + @Test + public void saveLogoutRequestWhenHttpServletRequestIsNullThenThrowIllegalArgumentException() { + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + assertThatIllegalArgumentException().isThrownBy(() -> this.logoutRequestRepository + .saveLogoutRequest(logoutRequest, null, new MockHttpServletResponse())); + } + + @Test + public void saveLogoutRequestWhenHttpServletResponseIsNullThenThrowIllegalArgumentException() { + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + assertThatIllegalArgumentException().isThrownBy(() -> this.logoutRequestRepository + .saveLogoutRequest(logoutRequest, new MockHttpServletRequest(), null)); + } + + @Test + public void saveLogoutRequestWhenStateNullThenThrowIllegalArgumentException() { + Saml2LogoutRequest logoutRequest = createLogoutRequest().relayState(null).build(); + assertThatIllegalArgumentException().isThrownBy(() -> this.logoutRequestRepository + .saveLogoutRequest(logoutRequest, new MockHttpServletRequest(), new MockHttpServletResponse())); + } + + @Test + public void saveLogoutRequestWhenNotNullThenSaved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, new MockHttpServletResponse()); + request.addParameter("RelayState", logoutRequest.getRelayState()); + Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(loadedLogoutRequest).isEqualTo(logoutRequest); + } + + @Test + public void saveLogoutRequestWhenNoExistingSessionAndDistributedSessionThenSaved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(new MockDistributedHttpSession()); + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, new MockHttpServletResponse()); + request.addParameter("RelayState", logoutRequest.getRelayState()); + Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(loadedLogoutRequest).isEqualTo(logoutRequest); + } + + @Test + public void saveLogoutRequestWhenExistingSessionAndDistributedSessionThenSaved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(new MockDistributedHttpSession()); + Saml2LogoutRequest logoutRequest1 = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest1, request, new MockHttpServletResponse()); + Saml2LogoutRequest logoutRequest2 = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest2, request, new MockHttpServletResponse()); + request.addParameter("RelayState", logoutRequest2.getRelayState()); + Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(loadedLogoutRequest).isEqualTo(logoutRequest2); + } + + @Test + public void saveLogoutRequestWhenNullThenRemoved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response); + request.addParameter("RelayState", logoutRequest.getRelayState()); + this.logoutRequestRepository.saveLogoutRequest(null, request, response); + Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(loadedLogoutRequest).isNull(); + } + + @Test + public void removeLogoutRequestWhenHttpServletRequestIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy( + () -> this.logoutRequestRepository.removeLogoutRequest(null, new MockHttpServletResponse())); + } + + @Test + public void removeLogoutRequestWhenHttpServletResponseIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.logoutRequestRepository.removeLogoutRequest(new MockHttpServletRequest(), null)); + } + + @Test + public void removeLogoutRequestWhenSavedThenRemoved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response); + request.addParameter("RelayState", logoutRequest.getRelayState()); + Saml2LogoutRequest removedLogoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response); + Saml2LogoutRequest loadedLogoutRequest = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(removedLogoutRequest).isNotNull(); + assertThat(loadedLogoutRequest).isNull(); + } + + // gh-5263 + @Test + public void removeLogoutRequestWhenSavedThenRemovedFromSession() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Saml2LogoutRequest logoutRequest = createLogoutRequest().build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response); + request.addParameter("RelayState", logoutRequest.getRelayState()); + Saml2LogoutRequest removedLogoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response); + String sessionAttributeName = HttpSessionLogoutRequestRepository.class.getName() + ".AUTHORIZATION_REQUEST"; + assertThat(removedLogoutRequest).isNotNull(); + assertThat(request.getSession().getAttribute(sessionAttributeName)).isNull(); + } + + @Test + public void removeLogoutRequestWhenNotSavedThenNotRemoved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter("RelayState", "state-1234"); + MockHttpServletResponse response = new MockHttpServletResponse(); + Saml2LogoutRequest removedLogoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response); + assertThat(removedLogoutRequest).isNull(); + } + + private Saml2LogoutRequest.Builder createLogoutRequest() { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + return Saml2LogoutRequest.withRelyingPartyRegistration(registration).samlRequest("request").id("id") + .parameters((params) -> params.put("RelayState", "state-1234")); + } + + static class MockDistributedHttpSession extends MockHttpSession { + + @Override + public Object getAttribute(String name) { + return wrap(super.getAttribute(name)); + } + + @Override + public void setAttribute(String name, Object value) { + super.setAttribute(name, wrap(value)); + } + + private Object wrap(Object object) { + if (object instanceof Map) { + object = new HashMap<>((Map) object); + } + return object; + } + + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java new file mode 100644 index 00000000000..5f291412187 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java @@ -0,0 +1,115 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.Test; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.saml2.Saml2Exception; +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.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OpenSamlLogoutRequestResolver} + * + * @author Josh Cummings + */ +public class OpenSamlLogoutRequestResolverTests { + + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class); + + OpenSamlLogoutRequestResolver logoutRequestResolver = new OpenSamlLogoutRequestResolver( + this.relyingPartyRegistrationResolver); + + @Test + public void resolveRedirectWhenAuthenticatedThenIncludesName() { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + Saml2Authentication authentication = authentication(registration); + HttpServletRequest request = new MockHttpServletRequest(); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + Saml2LogoutRequest saml2LogoutRequest = this.logoutRequestResolver.resolve(request, authentication); + assertThat(saml2LogoutRequest.getParameter("SigAlg")).isNotNull(); + assertThat(saml2LogoutRequest.getParameter("Signature")).isNotNull(); + assertThat(saml2LogoutRequest.getParameter("RelayState")).isNotNull(); + Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(); + LogoutRequest logoutRequest = getLogoutRequest(saml2LogoutRequest.getSamlRequest(), binding); + assertThat(logoutRequest.getNameID().getValue()).isEqualTo(authentication.getName()); + } + + @Test + public void resolvePostWhenAuthenticatedThenIncludesName() { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full() + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build(); + Saml2Authentication authentication = authentication(registration); + HttpServletRequest request = new MockHttpServletRequest(); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + Saml2LogoutRequest saml2LogoutRequest = this.logoutRequestResolver.resolve(request, authentication); + assertThat(saml2LogoutRequest.getParameter("SigAlg")).isNull(); + assertThat(saml2LogoutRequest.getParameter("Signature")).isNull(); + assertThat(saml2LogoutRequest.getParameter("RelayState")).isNotNull(); + Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(); + LogoutRequest logoutRequest = getLogoutRequest(saml2LogoutRequest.getSamlRequest(), binding); + assertThat(logoutRequest.getNameID().getValue()).isEqualTo(authentication.getName()); + } + + private Saml2Authentication authentication(RelyingPartyRegistration registration) { + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()); + principal.setRelyingPartyRegistrationId(registration.getRegistrationId()); + return new Saml2Authentication(principal, "response", new ArrayList<>()); + } + + private LogoutRequest getLogoutRequest(String samlRequest, Saml2MessageBinding binding) { + if (binding == Saml2MessageBinding.REDIRECT) { + samlRequest = Saml2Utils.samlInflate(Saml2Utils.samlDecode(samlRequest)); + } + else { + samlRequest = new String(Saml2Utils.samlDecode(samlRequest), StandardCharsets.UTF_8); + } + try { + Document document = XMLObjectProviderRegistrySupport.getParserPool() + .parse(new ByteArrayInputStream(samlRequest.getBytes(StandardCharsets.UTF_8))); + Element element = document.getDocumentElement(); + return (LogoutRequest) XMLObjectProviderRegistrySupport.getUnmarshallerFactory().getUnmarshaller(element) + .unmarshall(element); + } + catch (Exception ex) { + throw new Saml2Exception(ex); + } + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java new file mode 100644 index 00000000000..1958295c1ae --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java @@ -0,0 +1,125 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; + +import org.junit.jupiter.api.Test; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.LogoutResponse; +import org.opensaml.saml.saml2.core.StatusCode; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.Saml2Exception; +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.authentication.TestOpenSamlObjects; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OpenSamlLogoutResponseResolver} + * + * @author Josh Cummings + */ +public class OpenSamlLogoutResponseResolverTests { + + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class); + + OpenSamlLogoutResponseResolver logoutResponseResolver = new OpenSamlLogoutResponseResolver( + this.relyingPartyRegistrationResolver); + + @Test + public void resolveRedirectWhenAuthenticatedThenSuccess() { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + MockHttpServletRequest request = new MockHttpServletRequest(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + request.setParameter("SAMLRequest", + Saml2Utils.samlEncode(OpenSamlSigningUtils.serialize(logoutRequest).getBytes())); + request.setParameter("RelayState", "abcd"); + Authentication authentication = authentication(registration); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + Saml2LogoutResponse saml2LogoutResponse = this.logoutResponseResolver.resolve(request, authentication); + assertThat(saml2LogoutResponse.getParameter("SigAlg")).isNotNull(); + assertThat(saml2LogoutResponse.getParameter("Signature")).isNotNull(); + assertThat(saml2LogoutResponse.getParameter("RelayState")).isSameAs("abcd"); + Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(); + LogoutResponse logoutResponse = getLogoutResponse(saml2LogoutResponse.getSamlResponse(), binding); + assertThat(logoutResponse.getStatus().getStatusCode().getValue()).isEqualTo(StatusCode.SUCCESS); + } + + @Test + public void resolvePostWhenAuthenticatedThenSuccess() { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full() + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build(); + MockHttpServletRequest request = new MockHttpServletRequest(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + request.setParameter("SAMLRequest", + Saml2Utils.samlEncode(OpenSamlSigningUtils.serialize(logoutRequest).getBytes())); + request.setParameter("RelayState", "abcd"); + Authentication authentication = authentication(registration); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + Saml2LogoutResponse saml2LogoutResponse = this.logoutResponseResolver.resolve(request, authentication); + assertThat(saml2LogoutResponse.getParameter("SigAlg")).isNull(); + assertThat(saml2LogoutResponse.getParameter("Signature")).isNull(); + assertThat(saml2LogoutResponse.getParameter("RelayState")).isSameAs("abcd"); + Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(); + LogoutResponse logoutResponse = getLogoutResponse(saml2LogoutResponse.getSamlResponse(), binding); + assertThat(logoutResponse.getStatus().getStatusCode().getValue()).isEqualTo(StatusCode.SUCCESS); + } + + private Saml2Authentication authentication(RelyingPartyRegistration registration) { + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()); + principal.setRelyingPartyRegistrationId(registration.getRegistrationId()); + return new Saml2Authentication(principal, "response", new ArrayList<>()); + } + + private LogoutResponse getLogoutResponse(String saml2Response, Saml2MessageBinding binding) { + if (binding == Saml2MessageBinding.REDIRECT) { + saml2Response = Saml2Utils.samlInflate(Saml2Utils.samlDecode(saml2Response)); + } + else { + saml2Response = new String(Saml2Utils.samlDecode(saml2Response), StandardCharsets.UTF_8); + } + try { + Document document = XMLObjectProviderRegistrySupport.getParserPool() + .parse(new ByteArrayInputStream(saml2Response.getBytes(StandardCharsets.UTF_8))); + Element element = document.getDocumentElement(); + return (LogoutResponse) XMLObjectProviderRegistrySupport.getUnmarshallerFactory().getUnmarshaller(element) + .unmarshall(element); + } + catch (Exception ex) { + throw new Saml2Exception(ex); + } + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java new file mode 100644 index 00000000000..e4438b244cb --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java @@ -0,0 +1,155 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.saml2.core.Saml2Error; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link Saml2LogoutRequestFilter} + */ +public class Saml2LogoutRequestFilterTests { + + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class); + + Saml2LogoutRequestValidator logoutRequestValidator = mock(Saml2LogoutRequestValidator.class); + + LogoutHandler logoutHandler = mock(LogoutHandler.class); + + Saml2LogoutResponseResolver logoutResponseResolver = mock(Saml2LogoutResponseResolver.class); + + Saml2LogoutRequestFilter logoutRequestProcessingFilter = new Saml2LogoutRequestFilter( + this.relyingPartyRegistrationResolver, this.logoutRequestValidator, this.logoutResponseResolver, + this.logoutHandler); + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void doFilterWhenSamlRequestThenRedirects() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo"); + request.setServletPath("/logout/saml2/slo"); + request.setParameter("SAMLRequest", "request"); + MockHttpServletResponse response = new MockHttpServletResponse(); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + given(this.logoutRequestValidator.validate(any())).willReturn(Saml2LogoutValidatorResult.success()); + Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration) + .samlResponse("response").build(); + given(this.logoutResponseResolver.resolve(any(), any())).willReturn(logoutResponse); + this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.logoutRequestValidator).validate(any()); + verify(this.logoutHandler).logout(any(), any(), any()); + verify(this.logoutResponseResolver).resolve(any(), any()); + String content = response.getHeader("Location"); + assertThat(content).contains("SAMLResponse"); + assertThat(content) + .startsWith(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation()); + } + + @Test + public void doFilterWhenSamlRequestThenPosts() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full() + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build(); + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo"); + request.setServletPath("/logout/saml2/slo"); + request.setParameter("SAMLRequest", "request"); + MockHttpServletResponse response = new MockHttpServletResponse(); + given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration); + given(this.logoutRequestValidator.validate(any())).willReturn(Saml2LogoutValidatorResult.success()); + Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration) + .samlResponse("response").build(); + given(this.logoutResponseResolver.resolve(any(), any())).willReturn(logoutResponse); + this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.logoutRequestValidator).validate(any()); + verify(this.logoutHandler).logout(any(), any(), any()); + verify(this.logoutResponseResolver).resolve(any(), any()); + String content = response.getContentAsString(); + assertThat(content).contains("SAMLResponse"); + assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation()); + } + + @Test + public void doFilterWhenRequestMismatchesThenNoLogout() throws Exception { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout"); + request.setServletPath("/logout"); + request.setParameter("SAMLResponse", "response"); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verifyNoInteractions(this.logoutRequestValidator, this.logoutHandler); + } + + @Test + public void doFilterWhenNoSamlRequestOrResponseThenNoLogout() throws Exception { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo"); + request.setServletPath("/logout/saml2/slo"); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.logoutRequestProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verifyNoInteractions(this.logoutRequestValidator, this.logoutHandler); + } + + @Test + public void doFilterWhenValidationFailsThen401() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo"); + request.setServletPath("/logout/saml2/slo"); + request.setParameter("SAMLRequest", "request"); + MockHttpServletResponse response = new MockHttpServletResponse(); + given(this.relyingPartyRegistrationResolver.resolve(request, null)).willReturn(registration); + given(this.logoutRequestValidator.validate(any())) + .willReturn(Saml2LogoutValidatorResult.withErrors(new Saml2Error("error", "description")).build()); + this.logoutRequestProcessingFilter.doFilter(request, response, new MockFilterChain()); + assertThat(response.getStatus()).isEqualTo(401); + verifyNoInteractions(this.logoutHandler); + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java new file mode 100644 index 00000000000..da4c7dba90c --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java @@ -0,0 +1,153 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.saml2.core.Saml2Error; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link Saml2LogoutResponseFilter} + */ +public class Saml2LogoutResponseFilterTests { + + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = mock(RelyingPartyRegistrationResolver.class); + + Saml2LogoutRequestRepository logoutRequestRepository = mock(Saml2LogoutRequestRepository.class); + + Saml2LogoutResponseValidator logoutResponseValidator = mock(Saml2LogoutResponseValidator.class); + + LogoutSuccessHandler logoutSuccessHandler = mock(LogoutSuccessHandler.class); + + Saml2LogoutResponseFilter logoutResponseProcessingFilter = new Saml2LogoutResponseFilter( + this.relyingPartyRegistrationResolver, this.logoutResponseValidator, this.logoutSuccessHandler); + + @BeforeEach + public void setUp() { + this.logoutResponseProcessingFilter.setLogoutRequestRepository(this.logoutRequestRepository); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void doFilterWhenSamlResponsePostThenLogout() throws Exception { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo"); + request.setServletPath("/logout/saml2/slo"); + request.setParameter("SAMLResponse", "response"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + given(this.relyingPartyRegistrationResolver.resolve(request, "registration-id")).willReturn(registration); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest("request").build(); + given(this.logoutRequestRepository.removeLogoutRequest(request, response)).willReturn(logoutRequest); + given(this.logoutResponseValidator.validate(any())).willReturn(Saml2LogoutValidatorResult.success()); + this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.logoutResponseValidator).validate(any()); + verify(this.logoutSuccessHandler).onLogoutSuccess(any(), any(), any()); + } + + @Test + public void doFilterWhenSamlResponseRedirectThenLogout() throws Exception { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/logout/saml2/slo"); + request.setServletPath("/logout/saml2/slo"); + request.setParameter("SAMLResponse", "response"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full() + .singleLogoutServiceBinding(Saml2MessageBinding.REDIRECT).build(); + given(this.relyingPartyRegistrationResolver.resolve(request, "registration-id")).willReturn(registration); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest("request").build(); + given(this.logoutRequestRepository.removeLogoutRequest(request, response)).willReturn(logoutRequest); + given(this.logoutResponseValidator.validate(any())).willReturn(Saml2LogoutValidatorResult.success()); + this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.logoutResponseValidator).validate(any()); + verify(this.logoutSuccessHandler).onLogoutSuccess(any(), any(), any()); + } + + @Test + public void doFilterWhenRequestMismatchesThenNoLogout() throws Exception { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout"); + request.setServletPath("/logout"); + request.setParameter("SAMLRequest", "request"); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verifyNoInteractions(this.logoutResponseValidator, this.logoutSuccessHandler); + } + + @Test + public void doFilterWhenNoSamlRequestOrResponseThenNoLogout() throws Exception { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo"); + request.setServletPath("/logout/saml2/slo"); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verifyNoInteractions(this.logoutResponseValidator, this.logoutSuccessHandler); + } + + @Test + public void doFilterWhenValidatorFailsThenStops() throws Exception { + Authentication authentication = new TestingAuthenticationToken("user", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/logout/saml2/slo"); + request.setServletPath("/logout/saml2/slo"); + request.setParameter("SAMLResponse", "response"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + given(this.relyingPartyRegistrationResolver.resolve(request, "registration-id")).willReturn(registration); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest("request").build(); + given(this.logoutRequestRepository.removeLogoutRequest(request, response)).willReturn(logoutRequest); + given(this.logoutResponseValidator.validate(any())) + .willReturn(Saml2LogoutValidatorResult.withErrors(new Saml2Error("error", "description")).build()); + this.logoutResponseProcessingFilter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.logoutResponseValidator).validate(any()); + verifyNoInteractions(this.logoutSuccessHandler); + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests.java new file mode 100644 index 00000000000..5d63334b391 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests.java @@ -0,0 +1,107 @@ +/* + * 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.saml2.provider.service.web.authentication.logout; + +import java.util.ArrayList; +import java.util.HashMap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +/** + * Tests for {@link Saml2RelyingPartyInitiatedLogoutSuccessHandler} + * + * @author Josh Cummings + */ +public class Saml2RelyingPartyInitiatedLogoutSuccessHandlerTests { + + Saml2LogoutRequestResolver logoutRequestResolver = mock(Saml2LogoutRequestResolver.class); + + Saml2LogoutRequestRepository logoutRequestRepository = mock(Saml2LogoutRequestRepository.class); + + Saml2RelyingPartyInitiatedLogoutSuccessHandler logoutRequestSuccessHandler = new Saml2RelyingPartyInitiatedLogoutSuccessHandler( + this.logoutRequestResolver); + + @BeforeEach + public void setUp() { + this.logoutRequestSuccessHandler.setLogoutRequestRepository(this.logoutRequestRepository); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void onLogoutSuccessWhenRedirectThenRedirectsToAssertingParty() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + Authentication authentication = authentication(registration); + SecurityContextHolder.getContext().setAuthentication(authentication); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest("request").build(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/saml2/logout"); + request.setServletPath("/saml2/logout"); + MockHttpServletResponse response = new MockHttpServletResponse(); + given(this.logoutRequestResolver.resolve(any(), any())).willReturn(logoutRequest); + this.logoutRequestSuccessHandler.onLogoutSuccess(request, response, authentication); + String content = response.getHeader("Location"); + assertThat(content).contains("SAMLRequest"); + assertThat(content).startsWith(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation()); + } + + @Test + public void onLogoutSuccessWhenPostThenPostsToAssertingParty() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full() + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build(); + Authentication authentication = authentication(registration); + SecurityContextHolder.getContext().setAuthentication(authentication); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest("request").build(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/saml2/logout"); + request.setServletPath("/saml2/logout"); + MockHttpServletResponse response = new MockHttpServletResponse(); + given(this.logoutRequestResolver.resolve(any(), any())).willReturn(logoutRequest); + this.logoutRequestSuccessHandler.onLogoutSuccess(request, response, authentication); + String content = response.getContentAsString(); + assertThat(content).contains("SAMLRequest"); + assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation()); + } + + private Saml2Authentication authentication(RelyingPartyRegistration registration) { + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()); + principal.setRelyingPartyRegistrationId(registration.getRegistrationId()); + return new Saml2Authentication(principal, "response", new ArrayList<>()); + } + +} From 4b2945021759adf0720ddc2083739f4fb9144c59 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 25 Mar 2021 10:44:26 -0600 Subject: [PATCH 5/5] Add Saml2LogoutConfigurer Closes gh-9497 --- .../annotation/web/builders/HttpSecurity.java | 138 +++++ .../web/configurers/LogoutConfigurer.java | 5 +- .../saml2/Saml2LoginConfigurer.java | 11 +- .../saml2/Saml2LogoutConfigurer.java | 523 ++++++++++++++++++ .../configurers/LogoutConfigurerTests.java | 10 +- .../saml2/Saml2LogoutConfigurerTests.java | 493 +++++++++++++++++ .../_includes/servlet/saml2/saml2-login.adoc | 288 +++++----- 7 files changed, 1313 insertions(+), 155 deletions(-) create mode 100644 config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java 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 42a1f60f582..b4d35729fbd 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 @@ -72,6 +72,7 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.config.annotation.web.configurers.openid.OpenIDLoginConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; +import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -2209,6 +2210,143 @@ public HttpSecurity saml2Login(Customizer> sa return HttpSecurity.this; } + /** + * Configures logout support for an SAML 2.0 Relying Party.
+ *
+ * + * 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 sent a logout request to. The representation of the relying party and the + * asserting party is contained within {@link RelyingPartyRegistration}.
+ *
+ * + * {@link RelyingPartyRegistration}(s) are composed within a + * {@link RelyingPartyRegistrationRepository}, which is required and must be + * registered with the {@link ApplicationContext} or configured via + * {@link #saml2Login(Customizer)}.
+ *
+ * + * 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. + * + *
+	 *	@EnableWebSecurity
+	 *	@Configuration
+	 *	public class Saml2LogoutSecurityConfig {
+	 *		@Bean
+	 *		public SecurityFilterChain web(HttpSecurity http) throws Exception {
+	 *			http
+	 *				.authorizeRequests((authorize) -> authorize
+	 *					.anyRequest().authenticated()
+	 *				)
+	 *				.saml2Login(withDefaults())
+	 *				.saml2Logout(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 HttpSecurity} for further customizations + * @throws Exception + * @since 5.6 + */ + public HttpSecurity saml2Logout(Customizer> saml2LogoutCustomizer) + throws Exception { + saml2LogoutCustomizer.customize(getOrApply(new Saml2LogoutConfigurer<>(getContext()))); + return HttpSecurity.this; + } + + /** + * Configures logout support for an SAML 2.0 Relying Party.
+ *
+ * + * 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 sent a logout request to. The representation of the relying party and the + * asserting party is contained within {@link RelyingPartyRegistration}.
+ *
+ * + * {@link RelyingPartyRegistration}(s) are composed within a + * {@link RelyingPartyRegistrationRepository}, which is required and must be + * registered with the {@link ApplicationContext} or configured via + * {@link #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. + * + *
+	 *	@EnableWebSecurity
+	 *	@Configuration
+	 *	public class Saml2LogoutSecurityConfig {
+	 *		@Bean
+	 *		public SecurityFilterChain web(HttpSecurity http) throws Exception {
+	 *			http
+	 *				.authorizeRequests()
+	 *					.anyRequest().authenticated()
+	 *					.and()
+	 *				.saml2Login()
+	 *					.and()
+	 *				.saml2Logout();
+	 *			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 Saml2LoginConfigurer} for further customizations + * @throws Exception + * @since 5.6 + */ + public Saml2LogoutConfigurer saml2Logout() throws Exception { + return getOrApply(new Saml2LogoutConfigurer<>(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/LogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurer.java index a86d8339dcf..be651793021 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurer.java @@ -250,10 +250,11 @@ public LogoutConfigurer permitAll(boolean permitAll) { * {@link SimpleUrlLogoutSuccessHandler} using the {@link #logoutSuccessUrl(String)}. * @return the {@link LogoutSuccessHandler} to use */ - private LogoutSuccessHandler getLogoutSuccessHandler() { + public LogoutSuccessHandler getLogoutSuccessHandler() { LogoutSuccessHandler handler = this.logoutSuccessHandler; if (handler == null) { handler = createDefaultSuccessHandler(); + this.logoutSuccessHandler = handler; } return handler; } @@ -312,7 +313,7 @@ private String getLogoutSuccessUrl() { * Gets the {@link LogoutHandler} instances that will be used. * @return the {@link LogoutHandler} instances. Cannot be null. */ - List getLogoutHandlers() { + public List getLogoutHandlers() { return this.logoutHandlers; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java index 9860e8040b1..be18c5c5b2c 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -205,9 +205,7 @@ protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingU @Override public void init(B http) throws Exception { registerDefaultCsrfOverride(http); - if (this.relyingPartyRegistrationRepository == null) { - this.relyingPartyRegistrationRepository = getSharedOrBean(http, RelyingPartyRegistrationRepository.class); - } + relyingPartyRegistrationRepository(http); this.saml2WebSsoAuthenticationFilter = new Saml2WebSsoAuthenticationFilter(getAuthenticationConverter(http), this.loginProcessingUrl); setAuthenticationRequestRepository(http, this.saml2WebSsoAuthenticationFilter); @@ -257,6 +255,13 @@ public void configure(B http) throws Exception { } } + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(B http) { + if (this.relyingPartyRegistrationRepository == null) { + this.relyingPartyRegistrationRepository = getSharedOrBean(http, RelyingPartyRegistrationRepository.class); + } + return this.relyingPartyRegistrationRepository; + } + private void setAuthenticationRequestRepository(B http, Saml2WebSsoAuthenticationFilter saml2WebSsoAuthenticationFilter) { saml2WebSsoAuthenticationFilter.setAuthenticationRequestRepository(getAuthenticationRequestRepository(http)); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java new file mode 100644 index 00000000000..113d945c1d4 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java @@ -0,0 +1,523 @@ +/* + * 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.configurers.saml2; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.opensaml.core.Version; + +import org.springframework.context.ApplicationContext; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +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.config.annotation.web.configurers.LogoutConfigurer; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlLogoutRequestValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlLogoutResponseValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidator; +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.DefaultRelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.HttpSessionLogoutRequestRepository; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml3LogoutRequestResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml3LogoutResponseResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutRequestResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutResponseResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestFilter; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestRepository; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseFilter; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2RelyingPartyInitiatedLogoutSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessEventPublishingLogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.csrf.CsrfLogoutHandler; +import org.springframework.security.web.csrf.CsrfTokenRepository; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +/** + * Adds SAML 2.0 logout support. + * + *

Security Filters

+ * + * The following Filters are populated + * + *
    + *
  • {@link LogoutFilter}
  • + *
  • {@link Saml2LogoutRequestFilter}
  • + *
  • {@link Saml2LogoutResponseFilter}
  • + *
+ * + *

+ * The following configuration options are available: + * + *

    + *
  • {@link #logoutUrl} - The URL to to process SAML 2.0 Logout
  • + *
  • {@link LogoutRequestConfigurer#logoutRequestValidator} - The + * {@link AuthenticationManager} for authenticating SAML 2.0 Logout Requests
  • + *
  • {@link LogoutRequestConfigurer#logoutRequestResolver} - The + * {@link Saml2LogoutRequestResolver} for creating SAML 2.0 Logout Requests
  • + *
  • {@link LogoutRequestConfigurer#logoutRequestRepository} - The + * {@link Saml2LogoutRequestRepository} for storing SAML 2.0 Logout Requests
  • + *
  • {@link LogoutResponseConfigurer#logoutResponseValidator} - The + * {@link AuthenticationManager} for authenticating SAML 2.0 Logout Responses
  • + *
  • {@link LogoutResponseConfigurer#logoutResponseResolver} - The + * {@link Saml2LogoutResponseResolver} for creating SAML 2.0 Logout Responses
  • + *
+ * + *

Shared Objects Created

+ * + * No shared Objects are created + * + *

Shared Objects Used

+ * + * Uses {@link CsrfTokenRepository} to add the {@link CsrfLogoutHandler}. + * + * @author Josh Cummings + * @since 5.6 + * @see Saml2LogoutConfigurer + */ +public final class Saml2LogoutConfigurer> + extends AbstractHttpConfigurer, H> { + + private ApplicationContext context; + + private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + + private String logoutUrl = "/logout"; + + private List logoutHandlers = new ArrayList<>(); + + private LogoutSuccessHandler logoutSuccessHandler; + + private LogoutRequestConfigurer logoutRequestConfigurer; + + private LogoutResponseConfigurer logoutResponseConfigurer; + + /** + * Creates a new instance + * @see HttpSecurity#logout() + */ + public Saml2LogoutConfigurer(ApplicationContext context) { + this.context = context; + this.logoutHandlers.add(new SecurityContextLogoutHandler()); + this.logoutHandlers.add(new LogoutSuccessEventPublishingLogoutHandler()); + SimpleUrlLogoutSuccessHandler logoutSuccessHandler = new SimpleUrlLogoutSuccessHandler(); + logoutSuccessHandler.setDefaultTargetUrl("/login?logout"); + this.logoutSuccessHandler = logoutSuccessHandler; + this.logoutRequestConfigurer = new LogoutRequestConfigurer(); + this.logoutResponseConfigurer = new LogoutResponseConfigurer(); + } + + /** + * The URL by which the relying or asserting party can trigger logout. + * + *

+ * The Relying Party triggers logout by POSTing to the endpoint. The Asserting Party + * triggers logout based on what is specified by + * {@link RelyingPartyRegistration#getSingleLogoutServiceBinding()}. + * @param logoutUrl the URL that will invoke logout + * @return the {@link LogoutConfigurer} for further customizations + * @see LogoutConfigurer#logoutUrl(String) + * @see HttpSecurity#csrf() + */ + public Saml2LogoutConfigurer logoutUrl(String logoutUrl) { + this.logoutUrl = logoutUrl; + return this; + } + + /** + * Sets the {@link RelyingPartyRegistrationRepository} of relying parties, each party + * representing a service provider, SP and this host, and identity provider, IDP pair + * that communicate with each other. + * @param repo the repository of relying parties + * @return the {@link Saml2LogoutConfigurer} for further customizations + */ + public Saml2LogoutConfigurer relyingPartyRegistrationRepository(RelyingPartyRegistrationRepository repo) { + this.relyingPartyRegistrationRepository = repo; + return this; + } + + /** + * Get configurer for SAML 2.0 Logout Request components + * @return the {@link LogoutRequestConfigurer} for further customizations + */ + public LogoutRequestConfigurer logoutRequest() { + return this.logoutRequestConfigurer; + } + + /** + * Configures SAML 2.0 Logout Request components + * @param logoutRequestConfigurerCustomizer the {@link Customizer} to provide more + * options for the {@link LogoutRequestConfigurer} + * @return the {@link Saml2LogoutConfigurer} for further customizations + */ + public Saml2LogoutConfigurer logoutRequest( + Customizer logoutRequestConfigurerCustomizer) { + logoutRequestConfigurerCustomizer.customize(this.logoutRequestConfigurer); + return this; + } + + /** + * Get configurer for SAML 2.0 Logout Response components + * @return the {@link LogoutResponseConfigurer} for further customizations + */ + public LogoutResponseConfigurer logoutResponse() { + return this.logoutResponseConfigurer; + } + + /** + * Configures SAML 2.0 Logout Request components + * @param logoutResponseConfigurerCustomizer the {@link Customizer} to provide more + * options for the {@link LogoutResponseConfigurer} + * @return the {@link Saml2LogoutConfigurer} for further customizations + */ + public Saml2LogoutConfigurer logoutResponse( + Customizer logoutResponseConfigurerCustomizer) { + logoutResponseConfigurerCustomizer.customize(this.logoutResponseConfigurer); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public void configure(H http) throws Exception { + LogoutConfigurer logout = http.getConfigurer(LogoutConfigurer.class); + if (logout != null) { + this.logoutHandlers = logout.getLogoutHandlers(); + this.logoutSuccessHandler = logout.getLogoutSuccessHandler(); + } + RelyingPartyRegistrationResolver registrations = relyingPartyRegistrationResolver(http); + http.addFilterBefore(createLogoutRequestProcessingFilter(registrations), CsrfFilter.class); + http.addFilterBefore(createLogoutResponseProcessingFilter(registrations), CsrfFilter.class); + http.addFilterBefore(createRelyingPartyLogoutFilter(registrations), LogoutFilter.class); + } + + private RelyingPartyRegistrationResolver relyingPartyRegistrationResolver(H http) { + RelyingPartyRegistrationRepository registrations = getRelyingPartyRegistrationRepository(http); + return new DefaultRelyingPartyRegistrationResolver(registrations); + } + + private RelyingPartyRegistrationRepository getRelyingPartyRegistrationRepository(H http) { + if (this.relyingPartyRegistrationRepository != null) { + return this.relyingPartyRegistrationRepository; + } + Saml2LoginConfigurer login = http.getConfigurer(Saml2LoginConfigurer.class); + if (login != null) { + this.relyingPartyRegistrationRepository = login.relyingPartyRegistrationRepository(http); + } + else { + this.relyingPartyRegistrationRepository = getBeanOrNull(RelyingPartyRegistrationRepository.class); + } + return this.relyingPartyRegistrationRepository; + } + + private Saml2LogoutRequestFilter createLogoutRequestProcessingFilter( + RelyingPartyRegistrationResolver registrations) { + LogoutHandler[] logoutHandlers = this.logoutHandlers.toArray(new LogoutHandler[0]); + Saml2LogoutResponseResolver logoutResponseResolver = createSaml2LogoutResponseResolver(registrations); + Saml2LogoutRequestFilter filter = new Saml2LogoutRequestFilter(registrations, + this.logoutRequestConfigurer.logoutRequestValidator(), logoutResponseResolver, logoutHandlers); + filter.setLogoutRequestMatcher(createLogoutRequestMatcher()); + return filter; + } + + private Saml2LogoutResponseFilter createLogoutResponseProcessingFilter( + RelyingPartyRegistrationResolver registrations) { + Saml2LogoutResponseFilter logoutResponseFilter = new Saml2LogoutResponseFilter(registrations, + this.logoutResponseConfigurer.logoutResponseValidator(), this.logoutSuccessHandler); + logoutResponseFilter.setLogoutRequestMatcher(createLogoutResponseMatcher()); + logoutResponseFilter.setLogoutRequestRepository(this.logoutRequestConfigurer.logoutRequestRepository); + return logoutResponseFilter; + } + + private LogoutFilter createRelyingPartyLogoutFilter(RelyingPartyRegistrationResolver registrations) { + LogoutHandler[] logoutHandlers = this.logoutHandlers.toArray(new LogoutHandler[0]); + Saml2RelyingPartyInitiatedLogoutSuccessHandler logoutRequestSuccessHandler = createSaml2LogoutRequestSuccessHandler( + registrations); + LogoutFilter logoutFilter = new LogoutFilter(logoutRequestSuccessHandler, logoutHandlers); + logoutFilter.setLogoutRequestMatcher(createLogoutMatcher()); + return logoutFilter; + } + + private RequestMatcher createLogoutMatcher() { + RequestMatcher logout = new AntPathRequestMatcher(this.logoutUrl, "POST"); + RequestMatcher saml2 = new Saml2RequestMatcher(); + return new AndRequestMatcher(logout, saml2); + } + + private RequestMatcher createLogoutRequestMatcher() { + RequestMatcher logout = new AntPathRequestMatcher(this.logoutRequestConfigurer.logoutUrl); + RequestMatcher samlRequest = new ParameterRequestMatcher("SAMLRequest"); + return new AndRequestMatcher(logout, samlRequest); + } + + private RequestMatcher createLogoutResponseMatcher() { + RequestMatcher logout = new AntPathRequestMatcher(this.logoutResponseConfigurer.logoutUrl); + RequestMatcher samlResponse = new ParameterRequestMatcher("SAMLResponse"); + return new AndRequestMatcher(logout, samlResponse); + } + + private Saml2RelyingPartyInitiatedLogoutSuccessHandler createSaml2LogoutRequestSuccessHandler( + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + Saml2LogoutRequestResolver logoutRequestResolver = this.logoutRequestConfigurer + .logoutRequestResolver(relyingPartyRegistrationResolver); + return new Saml2RelyingPartyInitiatedLogoutSuccessHandler(logoutRequestResolver); + } + + private Saml2LogoutResponseResolver createSaml2LogoutResponseResolver( + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + return this.logoutResponseConfigurer.logoutResponseResolver(relyingPartyRegistrationResolver); + } + + 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); + } + + private String version() { + String version = Version.getVersion(); + if (version != null) { + return version; + } + return Version.class.getModule().getDescriptor().version().map(Object::toString) + .orElseThrow(() -> new IllegalStateException("cannot determine OpenSAML version")); + } + + /** + * A configurer for SAML 2.0 LogoutRequest components + */ + public final class LogoutRequestConfigurer { + + private String logoutUrl = "/logout/saml2/slo"; + + private Saml2LogoutRequestValidator logoutRequestValidator; + + private Saml2LogoutRequestResolver logoutRequestResolver; + + private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository(); + + LogoutRequestConfigurer() { + } + + /** + * 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#getSingleLogoutServiceBinding()}. + * @param logoutUrl the URL that will receive the SAML 2.0 Logout Request + * @return the {@link LogoutRequestConfigurer} for further customizations + * @see Saml2LogoutConfigurer#logoutUrl(String) + */ + public LogoutRequestConfigurer logoutUrl(String logoutUrl) { + this.logoutUrl = logoutUrl; + return this; + } + + /** + * Use this {@link LogoutHandler} for processing a logout request from the + * asserting party + * @param authenticator the {@link Saml2LogoutRequestValidator} to use + * @return the {@link LogoutRequestConfigurer} for further customizations + */ + public LogoutRequestConfigurer logoutRequestValidator(Saml2LogoutRequestValidator authenticator) { + this.logoutRequestValidator = authenticator; + return this; + } + + /** + * Use this {@link Saml2LogoutRequestResolver} for producing a logout request to + * send to the asserting party + * @param logoutRequestResolver the {@link Saml2LogoutRequestResolver} to use + * @return the {@link LogoutRequestConfigurer} for further customizations + */ + public LogoutRequestConfigurer logoutRequestResolver(Saml2LogoutRequestResolver logoutRequestResolver) { + this.logoutRequestResolver = logoutRequestResolver; + return this; + } + + /** + * Use this {@link Saml2LogoutRequestRepository} for storing logout requests + * @param logoutRequestRepository the {@link Saml2LogoutRequestRepository} to use + * @return the {@link LogoutRequestConfigurer} for further customizations + */ + public LogoutRequestConfigurer logoutRequestRepository(Saml2LogoutRequestRepository logoutRequestRepository) { + this.logoutRequestRepository = logoutRequestRepository; + return this; + } + + public Saml2LogoutConfigurer and() { + return Saml2LogoutConfigurer.this; + } + + private Saml2LogoutRequestValidator logoutRequestValidator() { + if (this.logoutRequestValidator == null) { + return new OpenSamlLogoutRequestValidator(); + } + return this.logoutRequestValidator; + } + + private Saml2LogoutRequestResolver logoutRequestResolver( + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + if (this.logoutRequestResolver != null) { + return this.logoutRequestResolver; + } + if (version().startsWith("4")) { + return new OpenSaml4LogoutRequestResolver(relyingPartyRegistrationResolver); + } + return new OpenSaml3LogoutRequestResolver(relyingPartyRegistrationResolver); + } + + } + + public final class LogoutResponseConfigurer { + + private String logoutUrl = "/logout/saml2/slo"; + + private Saml2LogoutResponseValidator logoutResponseValidator; + + private Saml2LogoutResponseResolver logoutResponseResolver; + + LogoutResponseConfigurer() { + } + + /** + * 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#getSingleLogoutServiceBinding()}. + * @param logoutUrl the URL that will receive the SAML 2.0 Logout Response + * @return the {@link LogoutResponseConfigurer} for further customizations + * @see Saml2LogoutConfigurer#logoutUrl(String) + */ + public LogoutResponseConfigurer logoutUrl(String logoutUrl) { + this.logoutUrl = logoutUrl; + return this; + } + + /** + * Use this {@link LogoutHandler} for processing a logout response from the + * asserting party + * @param authenticator the {@link AuthenticationManager} to use + * @return the {@link LogoutRequestConfigurer} for further customizations + */ + public LogoutResponseConfigurer logoutResponseValidator(Saml2LogoutResponseValidator authenticator) { + this.logoutResponseValidator = authenticator; + return this; + } + + /** + * Use this {@link Saml2LogoutRequestResolver} for producing a logout response to + * send to the asserting party + * @param logoutResponseResolver the {@link Saml2LogoutResponseResolver} to use + * @return the {@link LogoutRequestConfigurer} for further customizations + */ + public LogoutResponseConfigurer logoutResponseResolver(Saml2LogoutResponseResolver logoutResponseResolver) { + this.logoutResponseResolver = logoutResponseResolver; + return this; + } + + public Saml2LogoutConfigurer and() { + return Saml2LogoutConfigurer.this; + } + + private Saml2LogoutResponseValidator logoutResponseValidator() { + if (this.logoutResponseValidator == null) { + return new OpenSamlLogoutResponseValidator(); + } + return this.logoutResponseValidator; + } + + private Saml2LogoutResponseResolver logoutResponseResolver( + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + if (this.logoutResponseResolver == null) { + if (version().startsWith("4")) { + return new OpenSaml4LogoutResponseResolver(relyingPartyRegistrationResolver); + } + return new OpenSaml3LogoutResponseResolver(relyingPartyRegistrationResolver); + } + return this.logoutResponseResolver; + } + + } + + private static class Saml2RequestMatcher implements RequestMatcher { + + @Override + public boolean matches(HttpServletRequest request) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return false; + } + return authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal; + } + + } + + private static class ParameterRequestMatcher implements RequestMatcher { + + Predicate test = Objects::nonNull; + + String name; + + ParameterRequestMatcher(String name) { + this.name = name; + } + + @Override + public boolean matches(HttpServletRequest request) { + return this.test.test(request.getParameter(this.name)); + } + + } + + private static class NoopLogoutHandler implements LogoutHandler { + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerTests.java index 4fef78e9a1f..1433e0ec1b0 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/LogoutConfigurerTests.java @@ -97,7 +97,9 @@ public void configureWhenDefaultLogoutSuccessHandlerForHasNullMatcherInLambdaThe @Test public void configureWhenRegisteringObjectPostProcessorThenInvokedOnLogoutFilter() { this.spring.register(ObjectPostProcessorConfig.class).autowire(); - verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(LogoutFilter.class)); + ObjectPostProcessor objectPostProcessor = this.spring.getContext() + .getBean(ObjectPostProcessor.class); + verify(objectPostProcessor).postProcess(any(LogoutFilter.class)); } @Test @@ -361,7 +363,7 @@ protected void configure(HttpSecurity http) throws Exception { @EnableWebSecurity static class ObjectPostProcessorConfig extends WebSecurityConfigurerAdapter { - static ObjectPostProcessor objectPostProcessor = spy(ReflectingObjectPostProcessor.class); + ObjectPostProcessor objectPostProcessor = spy(ReflectingObjectPostProcessor.class); @Override protected void configure(HttpSecurity http) throws Exception { @@ -372,8 +374,8 @@ protected void configure(HttpSecurity http) throws Exception { } @Bean - static ObjectPostProcessor objectPostProcessor() { - return objectPostProcessor; + ObjectPostProcessor objectPostProcessor() { + return this.objectPostProcessor; } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java new file mode 100644 index 00000000000..c47654bf231 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java @@ -0,0 +1,493 @@ +/* + * 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.configurers.saml2; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.function.Consumer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.xmlsec.signature.support.SignatureConstants; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.TestingAuthenticationToken; +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.core.Saml2Utils; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.core.TestSaml2X509Credentials; +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.authentication.TestOpenSamlObjects; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequestValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponseValidator; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutValidatorResult; +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.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; +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; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseResolver; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.verifyNoInteractions; +import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for different Java configuration for {@link Saml2LogoutConfigurer} + */ +@ExtendWith(SpringTestContextExtension.class) +public class Saml2LogoutConfigurerTests { + + @Autowired + private ConfigurableApplicationContext context; + + @Autowired + private RelyingPartyRegistrationRepository repository; + + private final Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository(); + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired(required = false) + MockMvc mvc; + + private Saml2Authentication user; + + String apLogoutRequest = "nZFBa4MwGIb/iuQeE2NTXFDLQAaC26Hrdtgt1dQFNMnyxdH9+zlboeyww275SN7nzcOX787jEH0qD9qaAiUxRZEyre206Qv0cnjAGdqVOchxYE40trdT2KuPSUGI5qQBcbkq0OSNsBI0CCNHBSK04vn+sREspsJ5G2xrBxRVc1AbGZa29xAcCEK8i9VZjm5QsfU9GZYWsoCJv5ShqK4K1Ow5p5LyU4aP6XaLN3cpw9mGctydjrxNaZt1XM5vASZVGwjShAIxyhJMU8z4gSWCM8GSmDH+hqLX1Xv+JLpaiiXsb+3+lpMAyv8IoVI6rEzQ4QvrLie3uBX+NMfr6l/waT6t0AumvI6/FlN+Aw=="; + + String apLogoutRequestSigAlg = SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256; + + String apLogoutRequestRelayState = "33591874-b123-4f2c-ab0d-2d0d84aa8b56"; + + String apLogoutRequestSignature = "oKqdzrmn2YAqXcwkow2lzRXr5PNHm0s/gWsRnaZYhC+Oq5ekK5uIKQYvtmNR94HJjDe1VRs+vVQCYivgdoTzBV2ZlffTXZmYsCsY9q4jbCWR6R5CbhU73/MkKQsPcyVvMhNYxnDYapIlxDsfoZNTboDEz3GM+HRoGRfl9emCXY0lPRYwqC4kpu7oMDBkafR0A09jPIxFuNpqlLPwUxL9m+DGkvDK3mFDN1xJcgZaK73HcuJe7Qh4huOrKNFetwc5EvqfiwgiWF6sfq9A+rZBfCIYo10NNLY7fNQAR2IqwcKtawHgTGWbeshRyFrwVYMR64EnClfxUHsHKf5kiZ2dlw=="; + + String apLogoutResponse = "fZHRa4MwEMb/Fcl7jEadGqplrAwK3Uvb9WFvZ4ydoInk4uj++1nXbmWMvhwcd9/3Jb9bLE99530oi63RBQn9gHhKS1O3+liQ1/0zzciyXCD0HR/ExhzN6LYKB6NReZNUo/ieFWS0WhjAFoWGXqFwUuweXzaC+4EYrHFGmo54K4Wu1eDmuHfnBhSM2cFXJ+iHTvnGHlk3x7DZmNlLGvHWq4Jstk0GUSjjiIZJI2lcpQnNeRLTAOo4fwCeQg3Trr6+cm/OqmnWVHECVGWQ0jgCSatsKvXUxhFvZF7xSYU4qrVGB9oVhAc8pEFEebLnkeBc8NyPePpGvMOV1/Q3cqEjZrG9hXKfCSAqe+ZAShio0q51n7StF+zW7gf9zoEb8U/7ZGrlHaAb1f0onLfFbpRSIRJWXkJ+bdm/Fy6/AA=="; + + String apLogoutResponseSigAlg = SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256; + + String apLogoutResponseRelayState = "8f63887a-ec7e-4149-b6a0-dd730017f315"; + + String apLogoutResponseSignature = "h2fDqSIBfmnkRHKDMY4IxkCXcI0w98ydNsnPmv1b7GTZCWLbJ+oxaP2yZNPw7wOWXTv86cTPwKLjx5halKy5C+hhWnT0haKhuMcUvHlsgAMBbJKLV+1afzL4O77cvAQJmMNRK7ugXGNV5PTEnd1U4voy134OgdD5XycYiFVRZOwP5H84eJ9xxlvqQwqDvZTcgiF/ZS4ioZgzgnIFcbagZQ12LWNh26OMaUpIW04kCeO6t2dUsxOL6nZWvNrX/Zx1sORIpu4doDUa1RYC8YnjZeQEzDqUVC/dBO/mbVJ/hbF9tD0jBUx7YIgoXpqsWK4TcCsvmlmhrJXvGxDyoAWu2Q=="; + + String rpLogoutRequest = "nZFBa4MwGIb/iuQeY6NlGtQykIHgdui6HXaLmrqAJlm+OLp/v0wrlB122CXkI3mfNw/JD5dpDD6FBalVgXZhhAKhOt1LNRTo5fSAU3Qoc+DTSA1r9KBndxQfswAX+KQCth4VaLaKaQ4SmOKTAOY69nz/2DAaRsxY7XSnRxRUPigVd0vbu3MGGCHchOLCJzOKUNuBjEsLWcDErmUoqKsCNcc+yc5tsudYpPwOJzHvcJv6pfdjEtNzl7XU3wWYRa3AceUKRCO6w1GM6f5EY0Ypo1lIk+gNBa+bt38kulqyJWxv7f6W4wDC/gih0hoslJPuC8s+J7e4Df7k43X1L/jsdxt0xZTX8dfHlN8="; + + String rpLogoutRequestId = "LRd49fb45a-e8a7-43ac-b8ac-d8a7432fc9b2"; + + String rpLogoutRequestRelayState = "8f63887a-ec7e-4149-b6a0-dd730017f315"; + + String rpLogoutRequestSignature = "h2fDqSIBfmnkRHKDMY4IxkCXcI0w98ydNsnPmv1b7GTZCWLbJ+oxaP2yZNPw7wOWXTv86cTPwKLjx5halKy5C+hhWnT0haKhuMcUvHlsgAMBbJKLV+1afzL4O77cvAQJmMNRK7ugXGNV5PTEnd1U4voy134OgdD5XycYiFVRZOwP5H84eJ9xxlvqQwqDvZTcgiF/ZS4ioZgzgnIFcbagZQ12LWNh26OMaUpIW04kCeO6t2dUsxOL6nZWvNrX/Zx1sORIpu4doDUa1RYC8YnjZeQEzDqUVC/dBO/mbVJ/hbF9tD0jBUx7YIgoXpqsWK4TcCsvmlmhrJXvGxDyoAWu2Q=="; + + private MockHttpServletRequest request; + + private MockHttpServletResponse response; + + @BeforeEach + public void setup() { + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", + Collections.emptyMap()); + principal.setRelyingPartyRegistrationId("registration-id"); + this.user = new Saml2Authentication(principal, "response", AuthorityUtils.createAuthorityList("ROLE_USER")); + this.request = new MockHttpServletRequest("POST", ""); + this.request.setServletPath("/login/saml2/sso/test-rp"); + this.response = new MockHttpServletResponse(); + } + + @AfterEach + public void cleanup() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + public void logoutWhenDefaultsAndNotSaml2LoginThenDefaultLogout() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password"); + MvcResult result = this.mvc.perform(post("/logout").with(authentication(user)).with(csrf())) + .andExpect(status().isFound()).andReturn(); + String location = result.getResponse().getHeader("Location"); + LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class); + assertThat(location).isEqualTo("/login?logout"); + verify(logoutHandler).logout(any(), any(), any()); + } + + @Test + public void saml2LogoutWhenDefaultsThenLogsOutAndSendsLogoutRequest() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.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"); + LogoutHandler logoutHandler = this.spring.getContext().getBean(LogoutHandler.class); + assertThat(location).startsWith("https://ap.example.org/logout/saml2/request"); + verify(logoutHandler).logout(any(), any(), any()); + } + + @Test + public void saml2LogoutWhenUnauthenticatedThenEntryPoint() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + this.mvc.perform(post("/logout").with(csrf())).andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?logout")); + } + + @Test + public void saml2LogoutWhenMissingCsrfThen403() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + this.mvc.perform(post("/logout").with(authentication(this.user))).andExpect(status().isForbidden()); + verifyNoInteractions(getBean(LogoutHandler.class)); + } + + @Test + public void saml2LogoutWhenGetThenDefaultLogoutPage() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + MvcResult result = this.mvc.perform(get("/logout").with(authentication(this.user))).andExpect(status().isOk()) + .andReturn(); + assertThat(result.getResponse().getContentAsString()).contains("Are you sure you want to log out?"); + verifyNoInteractions(getBean(LogoutHandler.class)); + } + + @Test + public void saml2LogoutWhenPutOrDeleteThen404() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + this.mvc.perform(put("/logout").with(authentication(this.user)).with(csrf())).andExpect(status().isNotFound()); + this.mvc.perform(delete("/logout").with(authentication(this.user)).with(csrf())) + .andExpect(status().isNotFound()); + verifyNoInteractions(this.spring.getContext().getBean(LogoutHandler.class)); + } + + @Test + public void saml2LogoutWhenNoRegistrationThen401() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", + Collections.emptyMap()); + principal.setRelyingPartyRegistrationId("wrong"); + Saml2Authentication authentication = new Saml2Authentication(principal, "response", + AuthorityUtils.createAuthorityList("ROLE_USER")); + this.mvc.perform(post("/logout").with(authentication(authentication)).with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + public void saml2LogoutWhenCsrfDisabledAndNoAuthenticationThenFinalRedirect() throws Exception { + this.spring.register(Saml2LogoutCsrfDisabledConfig.class).autowire(); + this.mvc.perform(post("/logout")); + LogoutSuccessHandler logoutSuccessHandler = this.spring.getContext().getBean(LogoutSuccessHandler.class); + verify(logoutSuccessHandler).onLogoutSuccess(any(), any(), any()); + } + + @Test + public void saml2LogoutWhenCustomLogoutRequestResolverThenUses() throws Exception { + this.spring.register(Saml2LogoutComponentsConfig.class).autowire(); + RelyingPartyRegistration registration = this.repository.findByRegistrationId("registration-id"); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest(this.rpLogoutRequest).id(this.rpLogoutRequestId).relayState(this.rpLogoutRequestRelayState) + .parameters((params) -> params.put("Signature", this.rpLogoutRequestSignature)).build(); + given(getBean(Saml2LogoutRequestResolver.class).resolve(any(), any())).willReturn(logoutRequest); + this.mvc.perform(post("/logout").with(authentication(this.user)).with(csrf())); + verify(getBean(Saml2LogoutRequestResolver.class)).resolve(any(), any()); + } + + @Test + public void saml2LogoutRequestWhenDefaultsThenLogsOutAndSendsLogoutResponse() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", + Collections.emptyMap()); + principal.setRelyingPartyRegistrationId("get"); + Saml2Authentication user = new Saml2Authentication(principal, "response", + AuthorityUtils.createAuthorityList("ROLE_USER")); + MvcResult result = this.mvc + .perform(get("/logout/saml2/slo").param("SAMLRequest", this.apLogoutRequest) + .param("RelayState", this.apLogoutRequestRelayState).param("SigAlg", this.apLogoutRequestSigAlg) + .param("Signature", this.apLogoutRequestSignature).with(authentication(user))) + .andExpect(status().isFound()).andReturn(); + String location = result.getResponse().getHeader("Location"); + assertThat(location).startsWith("https://ap.example.org/logout/saml2/response"); + verify(getBean(LogoutHandler.class)).logout(any(), any(), any()); + } + + @Test + public void saml2LogoutRequestWhenNoRegistrationThen400() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal("user", + Collections.emptyMap()); + principal.setRelyingPartyRegistrationId("wrong"); + Saml2Authentication user = new Saml2Authentication(principal, "response", + AuthorityUtils.createAuthorityList("ROLE_USER")); + this.mvc.perform(get("/logout/saml2/slo").param("SAMLRequest", this.apLogoutRequest) + .param("RelayState", this.apLogoutRequestRelayState).param("SigAlg", this.apLogoutRequestSigAlg) + .param("Signature", this.apLogoutRequestSignature).with(authentication(user))) + .andExpect(status().isBadRequest()); + verifyNoInteractions(getBean(LogoutHandler.class)); + } + + @Test + public void saml2LogoutRequestWhenInvalidSamlRequestThen401() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + this.mvc.perform(get("/logout/saml2/slo").param("SAMLRequest", this.apLogoutRequest) + .param("RelayState", this.apLogoutRequestRelayState).param("SigAlg", this.apLogoutRequestSigAlg) + .with(authentication(this.user))).andExpect(status().isUnauthorized()); + verifyNoInteractions(getBean(LogoutHandler.class)); + } + + @Test + public void saml2LogoutRequestWhenCustomLogoutRequestHandlerThenUses() throws Exception { + this.spring.register(Saml2LogoutComponentsConfig.class).autowire(); + RelyingPartyRegistration registration = this.repository.findByRegistrationId("registration-id"); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.setIssueInstant(Instant.now()); + given(getBean(Saml2LogoutRequestValidator.class).validate(any())) + .willReturn(Saml2LogoutValidatorResult.success()); + Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration).build(); + given(getBean(Saml2LogoutResponseResolver.class).resolve(any(), any())).willReturn(logoutResponse); + this.mvc.perform(post("/logout/saml2/slo").param("SAMLRequest", "samlRequest").with(authentication(this.user))) + .andReturn(); + verify(getBean(Saml2LogoutRequestValidator.class)).validate(any()); + verify(getBean(Saml2LogoutResponseResolver.class)).resolve(any(), any()); + } + + @Test + public void saml2LogoutResponseWhenDefaultsThenRedirects() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + RelyingPartyRegistration registration = this.repository.findByRegistrationId("get"); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest(this.rpLogoutRequest).id(this.rpLogoutRequestId).relayState(this.rpLogoutRequestRelayState) + .parameters((params) -> params.put("Signature", this.rpLogoutRequestSignature)).build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, this.request, this.response); + this.request.setParameter("RelayState", logoutRequest.getRelayState()); + assertThat(this.logoutRequestRepository.loadLogoutRequest(this.request)).isNotNull(); + this.mvc.perform(get("/logout/saml2/slo").session(((MockHttpSession) this.request.getSession())) + .param("SAMLResponse", this.apLogoutResponse).param("RelayState", this.apLogoutResponseRelayState) + .param("SigAlg", this.apLogoutResponseSigAlg).param("Signature", this.apLogoutResponseSignature)) + .andExpect(status().isFound()).andExpect(redirectedUrl("/login?logout")); + verifyNoInteractions(getBean(LogoutHandler.class)); + assertThat(this.logoutRequestRepository.loadLogoutRequest(this.request)).isNull(); + } + + @Test + public void saml2LogoutResponseWhenInvalidSamlResponseThen401() throws Exception { + this.spring.register(Saml2LogoutDefaultsConfig.class).autowire(); + RelyingPartyRegistration registration = this.repository.findByRegistrationId("registration-id"); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest(this.rpLogoutRequest).id(this.rpLogoutRequestId).relayState(this.rpLogoutRequestRelayState) + .parameters((params) -> params.put("Signature", this.rpLogoutRequestSignature)).build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, this.request, this.response); + String deflatedApLogoutResponse = Saml2Utils.samlEncode( + Saml2Utils.samlInflate(Saml2Utils.samlDecode(this.apLogoutResponse)).getBytes(StandardCharsets.UTF_8)); + this.mvc.perform(post("/logout/saml2/slo").session((MockHttpSession) this.request.getSession()) + .param("SAMLResponse", deflatedApLogoutResponse).param("RelayState", this.rpLogoutRequestRelayState) + .param("SigAlg", this.apLogoutRequestSigAlg).param("Signature", this.apLogoutResponseSignature)) + .andExpect(status().reason(containsString("invalid_signature"))).andExpect(status().isUnauthorized()); + verifyNoInteractions(getBean(LogoutHandler.class)); + } + + @Test + public void saml2LogoutResponseWhenCustomLogoutResponseHandlerThenUses() throws Exception { + this.spring.register(Saml2LogoutComponentsConfig.class).autowire(); + RelyingPartyRegistration registration = this.repository.findByRegistrationId("get"); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration) + .samlRequest(this.rpLogoutRequest).id(this.rpLogoutRequestId).relayState(this.rpLogoutRequestRelayState) + .parameters((params) -> params.put("Signature", this.rpLogoutRequestSignature)).build(); + given(getBean(Saml2LogoutRequestRepository.class).removeLogoutRequest(any(), any())).willReturn(logoutRequest); + given(getBean(Saml2LogoutResponseValidator.class).validate(any())) + .willReturn(Saml2LogoutValidatorResult.success()); + this.mvc.perform(get("/logout/saml2/slo").param("SAMLResponse", "samlResponse")).andReturn(); + verify(getBean(Saml2LogoutResponseValidator.class)).validate(any()); + } + + private T getBean(Class clazz) { + return this.spring.getContext().getBean(clazz); + } + + @EnableWebSecurity + @Import(Saml2LoginConfigBeans.class) + static class Saml2LogoutDefaultsConfig { + + LogoutHandler mockLogoutHandler = mock(LogoutHandler.class); + + @Bean + SecurityFilterChain web(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests((authorize) -> authorize.anyRequest().authenticated()) + .logout((logout) -> logout.addLogoutHandler(this.mockLogoutHandler)) + .saml2Login(withDefaults()) + .saml2Logout(withDefaults()); + return http.build(); + // @formatter:on + } + + @Bean + LogoutHandler logoutHandler() { + return this.mockLogoutHandler; + } + + } + + @EnableWebSecurity + @Import(Saml2LoginConfigBeans.class) + static class Saml2LogoutCsrfDisabledConfig { + + LogoutSuccessHandler mockLogoutSuccessHandler = mock(LogoutSuccessHandler.class); + + @Bean + SecurityFilterChain web(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests((authorize) -> authorize.anyRequest().authenticated()) + .logout((logout) -> logout.logoutSuccessHandler(this.mockLogoutSuccessHandler)) + .saml2Login(withDefaults()) + .saml2Logout(withDefaults()) + .csrf().disable(); + return http.build(); + // @formatter:on + } + + @Bean + LogoutSuccessHandler logoutSuccessHandler() { + return this.mockLogoutSuccessHandler; + } + + } + + @EnableWebSecurity + @Import(Saml2LoginConfigBeans.class) + static class Saml2LogoutComponentsConfig { + + Saml2LogoutRequestRepository logoutRequestRepository = mock(Saml2LogoutRequestRepository.class); + + Saml2LogoutRequestValidator logoutRequestValidator = mock(Saml2LogoutRequestValidator.class); + + Saml2LogoutRequestResolver logoutRequestResolver = mock(Saml2LogoutRequestResolver.class); + + Saml2LogoutResponseValidator logoutResponseValidator = mock(Saml2LogoutResponseValidator.class); + + Saml2LogoutResponseResolver logoutResponseResolver = mock(Saml2LogoutResponseResolver.class); + + @Bean + SecurityFilterChain web(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests((authorize) -> authorize.anyRequest().authenticated()) + .saml2Login(withDefaults()) + .saml2Logout((logout) -> logout + .logoutRequest((request) -> request + .logoutRequestRepository(this.logoutRequestRepository) + .logoutRequestValidator(this.logoutRequestValidator) + .logoutRequestResolver(this.logoutRequestResolver) + ) + .logoutResponse((response) -> response + .logoutResponseValidator(this.logoutResponseValidator) + .logoutResponseResolver(this.logoutResponseResolver) + ) + ); + return http.build(); + // @formatter:on + } + + @Bean + Saml2LogoutRequestRepository logoutRequestRepository() { + return this.logoutRequestRepository; + } + + @Bean + Saml2LogoutRequestValidator logoutRequestAuthenticator() { + return this.logoutRequestValidator; + } + + @Bean + Saml2LogoutRequestResolver logoutRequestResolver() { + return this.logoutRequestResolver; + } + + @Bean + Saml2LogoutResponseValidator logoutResponseAuthenticator() { + return this.logoutResponseValidator; + } + + @Bean + Saml2LogoutResponseResolver logoutResponseResolver() { + return this.logoutResponseResolver; + } + + } + + static class Saml2LoginConfigBeans { + + @Bean + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() { + Saml2X509Credential signing = TestSaml2X509Credentials.assertingPartySigningCredential(); + Saml2X509Credential verification = TestSaml2X509Credentials.relyingPartyVerifyingCredential(); + RelyingPartyRegistration.Builder withCreds = TestRelyingPartyRegistrations.noCredentials() + .signingX509Credentials(credential(signing)) + .assertingPartyDetails((party) -> party.verificationX509Credentials(credential(verification))); + RelyingPartyRegistration post = withCreds.build(); + RelyingPartyRegistration get = withCreds.registrationId("get") + .singleLogoutServiceBinding(Saml2MessageBinding.REDIRECT).build(); + RelyingPartyRegistration ap = withCreds.registrationId("ap").entityId("ap-entity-id") + .assertingPartyDetails((party) -> party + .singleLogoutServiceLocation("https://rp.example.org/logout/saml2/request") + .singleLogoutServiceResponseLocation("https://rp.example.org/logout/saml2/response")) + .build(); + + return new InMemoryRelyingPartyRegistrationRepository(ap, get, post); + } + + private Consumer> credential(Saml2X509Credential credential) { + return (credentials) -> credentials.add(credential); + } + + } + +} diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc index 9092346b2b7..b4483fa5bb1 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc @@ -1,4 +1,5 @@ + [[servlet-saml2login]] == SAML 2.0 Login :figures: images/servlet/saml2 @@ -791,7 +792,7 @@ spring: okta: signing.credentials: &relying-party-credentials - private-key-location: classpath:rp.key - - certificate-location: classpath:rp.crt + certificate-location: classpath:rp.crt identityprovider: entity-id: ... azure: @@ -1639,9 +1640,7 @@ To use Spring Security's SAML 2.0 Single Logout feature, you will need the follo * Second, the asserting party should be configured to sign and POST `saml2:LogoutRequest` s and `saml2:LogoutResponse` s your application's `/logout/saml2/slo` endpoint * Third, your application must have a PKCS#8 private key and X.509 certificate for signing `saml2:LogoutRequest` s and `saml2:LogoutResponse` s -==== RP-Initiated Single Logout - -Given those, then for RP-initiated Single Logout, you can begin from the initial minimal example and add the following configuration: +You can begin from the initial minimal example and add the following configuration: [source,java] ---- @@ -1650,48 +1649,31 @@ Given those, then for RP-initiated Single Logout, you can begin from the initial @Bean RelyingPartyRegistrationRepository registrations() { - RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations + Saml2X509Credential credential = Saml2X509Credential.signing(key, certificate); + RelyingPartyRegistration registration = RelyingPartyRegistrations .fromMetadataLocation("https://ap.example.org/metadata") .registrationId("id") - .singleLogoutServiceLocation("{baseUrl}/logout/saml2/slo") - .signingX509Credentials((signing) -> signing.add(Saml2X509Credential.signing(key, certificate))) <1> + .signingX509Credentials((signing) -> signing.add(credential)) <1> .build(); - return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistration); + return new InMemoryRelyingPartyRegistrationRepository(registration); } @Bean SecurityFilterChain web(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception { - RelyingPartyRegistrationResolver registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrations); - LogoutHandler logoutResponseHandler = logoutResponseHandler(registrationResolver); - LogoutSuccessHandler logoutRequestSuccessHandler = logoutRequestSuccessHandler(registrationResolver); - http .authorizeRequests((authorize) -> authorize .anyRequest().authenticated() ) .saml2Login(withDefaults()) - .logout((logout) -> logout - .logoutUrl("/saml2/logout") - .logoutSuccessHandler(successHandler)) - .addFilterBefore(new Saml2LogoutResponseFilter(logoutHandler), CsrfFilter.class); + .saml2Logout(withDefaults()); <2> return http.build(); } - -private LogoutSuccessHandler logoutRequestSuccessHandler(RelyingPartyRegistrationResolver registrationResolver) { <2> - OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver(registrationResolver); - return new Saml2LogoutRequestSuccessHandler(logoutRequestResolver); -} - -private LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { <3> - return new OpenSamlLogoutResponseHandler(relyingPartyRegistrationResolver); -} ---- <1> - First, add your signing key to the `RelyingPartyRegistration` instance or to <> -<2> - Second, supply a `LogoutSuccessHandler` for initiating Single Logout, sending a `saml2:LogoutRequest` to the asserting party -<3> - Third, supply the `LogoutHandler` s needed to handle the `saml2:LogoutResponse` s sent from the asserting party. +<2> - Second, indicate that your application wants to use SAML SLO to logout the end user -==== Runtime Expectations for RP-Initiated +==== Runtime Expectations Given the above configuration any logged in user can send a `POST /logout` to your application to perform RP-initiated SLO. Your application will then do the following: @@ -1702,86 +1684,30 @@ Your application will then do the following: 4. Deserialize, verify, and process the `` sent by the asserting party 5. Redirect to any configured successful logout endpoint -[TIP] -If your asserting party does not send `` s when logout is complete, the asserting party can still send a `POST /saml2/logout` and then there is no need to configure the `Saml2LogoutResponseHandler`. - -==== AP-Initiated Single Logout - -Instead of RP-initiated Single Logout, you can again begin from the initial minimal example and add the following configuration to achieve AP-initiated Single Logout: - -[source,java] ----- -@Value("${private.key}") RSAPrivateKey key; -@Value("${public.certificate}") X509Certificate certificate; - -@Bean -RelyingPartyRegistrationRepository registrations() { - RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations - .fromMetadataLocation("https://ap.example.org/metadata") - .registrationId("id") - .signingX509Credentials((signing) -> signing.add(Saml2X509Credential.signing(key, certificate))) <1> - .build(); - return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistration); -} - -@Bean -SecurityFilterChain web(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception { - RelyingPartyRegistrationResolver registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrations); - LogoutHandler logoutRequestHandler = logoutRequestHandler(registrationResolver); - LogoutSuccessHandler logoutResponseSuccessHandler = logoutResponseSuccessHandler(registrationResolver); - - http - .authorizeRequests((authorize) -> authorize - .anyRequest().authenticated() - ) - .saml2Login(withDefaults()) - .addFilterBefore(new Saml2LogoutRequestFilter(logoutResponseSuccessHandler, logoutRequestHandler), CsrfFilter.class); - - return http.build(); -} - -private LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { <2> - return new CompositeLogoutHandler( - new OpenSamlLogoutRequestHandler(relyingPartyRegistrationResolver), - new SecurityContextLogoutHandler(), - new LogoutSuccessEventPublishingLogoutHandler()); -} - -private LogoutSuccessHandler logoutSuccessHandler(RelyingPartyRegistrationResolver registrationResolver) { <3> - OpenSaml4LogoutResponseResolver logoutResponseResolver = new OpenSaml4LogoutResponseResolver(registrationResolver); - return new Saml2LogoutResponseSuccessHandler(logoutResponseResolver); -} ----- -<1> - First, add your signing key to the `RelyingPartyRegistration` instance or to <> -<2> - Second, supply the `LogoutHandler` needed to handle the `saml2:LogoutRequest` s sent from the asserting party. -<3> - Third, supply a `LogoutSuccessHandler` for completing Single Logout, sending a `saml2:LogoutResponse` to the asserting party - -==== Runtime Expectations for AP-Initiated - -Given the above configuration, an asserting party can send a `POST /logout/saml2` to your application that includes a `` -Also, your application can participate in an AP-initated logout when the asserting party sends a `` to `/logout/saml2/slo`: +Also, your application can participate in an AP-initiated logout when the asserting party sends a `` to `/logout/saml2/slo`: 1. Use a `Saml2LogoutRequestHandler` to deserialize, verify, and process the `` sent by the asserting party 2. Logout the user and invalidate the session 3. Create, sign, and serialize a `` based on the <> associated with the just logged-out user 4. Send a redirect or post to the asserting party based on the <> -[TIP] -If your asserting party does not expect you do send a `` s when logout is complete, you may not need to configure a `LogoutSuccessHandler` +=== Configuring Logout Endpoints -[NOTE] -In the event that you need to support both logout flows, you can combine the above to configurations. +There are three behaviors that can be triggered by different endpoints: +* RP-initiated logout, which allows an authenticated user to `POST` and trigger the logout process by sending the asserting party a `` +* AP-initiated logout, which allows an asserting party to send a `` to the application +* AP logout response, which allows an asserting party to send a `` in response to the RP-initiated `` -=== Configuring Logout Endpoints +The first is triggered by performing normal `POST /logout` when the principal is of type `Saml2AuthenticatedPrincipal`. -There are three default endpoints that Spring Security's SAML 2.0 Single Logout support exposes: -* `/logout` - the endpoint for initiating single logout with an asserting party -* `/logout/saml2/slo` - the endpoint for receiving logout requests or responses from an asserting party +The second is triggered by POSTing to the `/logout/saml2/slo` endpoint with a `SAMLRequest` signed by the asserting party. -Because the user is already logged in, the `registrationId` is already known. +The third is triggered by POSTing to the `/logout/saml2/slo` endpoint with a `SAMLResponse` signed by the asserting party. + +Because the user is already logged in or the original Logout Request is known, the `registrationId` is already known. For this reason, `+{registrationId}+` is not part of these URLs by default. -These URLs are customizable in the DSL. +This URL is customizable in the DSL. For example, if you are migrating your existing relying party over to Spring Security, your asserting party may already be pointing to `GET /SLOService.saml2`. To reduce changes in configuration for the asserting party, you can configure the filter in the DSL like so: @@ -1790,12 +1716,15 @@ To reduce changes in configuration for the asserting party, you can configure th .Java [source,java,role="primary"] ---- -Saml2LogoutResponseFilter filter = new Saml2LogoutResponseFilter(logoutHandler); -filter.setLogoutRequestMatcher(new AntPathRequestMatcher("/SLOService.saml2", "GET")); http - // ... - .addFilterBefore(filter, CsrfFilter.class); + .saml2Logout((saml2) -> saml2 + .logoutRequest((request) -> request.logoutUrl("/SLOService.saml2")) + .logoutResponse((response) -> response.logoutUrl("/SLOService.saml2")) + ); ---- +==== + +You should also configure these endpoints in your `RelyingPartyRegistration`. === Customizing `` Resolution @@ -1812,22 +1741,33 @@ To add other values, you can use delegation, like so: [source,java] ---- -OpenSamlLogoutRequestResolver delegate = new OpenSamlLogoutRequestResolver(registrationResolver); -return (request, response, authentication) -> { - OpenSamlLogoutRequestBuilder builder = delegate.resolveLogoutRequest(request, response, authentication); <1> - builder.name(((Saml2AuthenticatedPrincipal) authentication.getPrincipal()).getFirstAttribute("CustomAttribute")); <2> - builder.logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(DateTime.now())); - return builder.logoutRequest(); <3> -}; +@Bean +Saml2LogoutRequestResolver logoutRequestResolver(RelyingPartyRegistrationResolver registrationResolver) { + OpenSaml4LogoutRequestResolver logoutRequestResolver + new OpenSaml4LogoutRequestResolver(registrationResolver); + logoutRequestResolver.setParametersConsumer((parameters) -> { + String name = ((Saml2AuthenticatedPrincipal) parameters.getAuthentication().getPrincipal()).getFirstAttribute("CustomAttribute"); + String format = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"; + LogoutRequest logoutRequest = parameters.getLogoutRequest(); + NameID nameId = logoutRequest.getNameID(); + nameId.setValue(name); + nameId.setFormat(format); + }); + return logoutRequestResolver; +} ---- -<1> - Spring Security applies default values to a `` -<2> - Your application specifies customizations -<3> - You complete the invocation by calling `request()` -[NOTE] -Support for OpenSAML 4 is coming. -In anticipation of that, `OpenSamlLogoutRequestResolver` does not add an `IssueInstant`. -Once OpenSAML 4 support is added, the default will be able to appropriate negotiate that datatype change, meaning you will no longer have to set it. +Then, you can supply your custom `Saml2LogoutRequestResolver` in the DSL as follows: + +[source,java] +---- +http + .saml2Logout((saml2) -> saml2 + .logoutRequest((request) -> request + .logoutRequestResolver(this.logoutRequestResolver) + ) + ); +---- === Customizing `` Resolution @@ -1844,55 +1784,111 @@ To add other values, you can use delegation, like so: [source,java] ---- -OpenSamlLogoutResponseResolver delegate = new OpenSamlLogoutResponseResolver(registrationResolver); -return (request, response, authentication) -> { - OpenSamlLogoutResponseBuilder builder = delegate.resolveLogoutResponse(request, response, authentication); <1> - if (checkOtherPrevailingConditions()) { - builder.status(StatusCode.PARTIAL_LOGOUT); <2> - } - builder.logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(DateTime.now())); - return builder.logoutResponse(); <3> -}; +@Bean +public Saml2LogoutResponseResolver logoutResponseResolver(RelyingPartyRegistrationResolver registrationResolver) { + OpenSaml4LogoutResponseResolver logoutRequestResolver = + new OpenSaml3LogoutResponseResolver(relyingPartyRegistrationResolver); + logoutRequestResolver.setParametersConsumer((parameters) -> { + if (checkOtherPrevailingConditions(parameters.getRequest())) { + parameters.getLogoutRequest().getStatus().getStatusCode().setCode(StatusCode.PARTIAL_LOGOUT); + } + }); + return logoutRequestResolver; +} ---- -<1> - Spring Security applies default values to a `` -<2> - Your application specifies customizations -<3> - You complete the invocation by calling `response()` -[NOTE] -Support for OpenSAML 4 is coming. -In anticipation of that, `OpenSamlLogoutResponseResolver` does not add an `IssueInstant`. -Once OpenSAML 4 support is added, the default will be able to appropriate negotiate that datatype change, meaning you will no longer have to set it. +Then, you can supply your custom `Saml2LogoutResponseResolver` in the DSL as follows: -=== Customizing `` Validation +[source,java] +---- +http + .saml2Logout((saml2) -> saml2 + .logoutRequest((request) -> request + .logoutRequestResolver(this.logoutRequestResolver) + ) + ); +---- + +=== Customizing `` Authentication -To customize validation, you can implement your own `LogoutHandler`. -At this point, the validation is minimal, so you may be able to first delegate to the default `LogoutHandler` like so: +To customize validation, you can implement your own `Saml2LogoutRequestValidator`. +At this point, the validation is minimal, so you may be able to first delegate to the default `Saml2LogoutRequestValidator` like so: [source,java] ---- -LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { - OpenSamlLogoutRequestHandler delegate = new OpenSamlLogoutRequestHandler(registrationResolver); - return (request, response, authentication) -> { - delegate.logout(request, response, authentication); // verify signature, issuer, destination, and principal name +@Component +public class MyOpenSamlLogoutRequestValidator implements Saml2LogoutRequestValidator { + private final Saml2LogoutRequestValidator delegate = new OpenSamlLogoutRequestValidator(); + + @Override + public Saml2LogoutRequestValidator logout(Saml2LogoutRequestValidatorParameters parameters) { + // verify signature, issuer, destination, and principal name + Saml2LogoutValidatorResult result = delegate.authenticate(authentication); + LogoutRequest logoutRequest = // ... parse using OpenSAML // perform custom validation - } + } } ---- -=== Customizing `` Validation +Then, you can supply your custom `Saml2LogoutRequestValidator` in the DSL as follows: -To customize validation, you can implement your own `LogoutHandler`. -At this point, the validation is minimal, so you may be able to first delegate to the default `LogoutHandler` like so: +[source,java] +---- +http + .saml2Logout((saml2) -> saml2 + .logoutRequest((request) -> request + .logoutRequestAuthenticator(myOpenSamlLogoutRequestAuthenticator) + ) + ); +---- + +=== Customizing `` Authentication + +To customize validation, you can implement your own `Saml2LogoutResponseValidator`. +At this point, the validation is minimal, so you may be able to first delegate to the default `Saml2LogoutResponseValidator` like so: [source,java] ---- -LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { - OpenSamlLogoutResponseHandler delegate = new OpenSamlLogoutResponseHandler(registrationResolver); - return (request, response, authentication) -> { - delegate.logout(request, response, authentication); // verify signature, issuer, destination, and status +@Component +public class MyOpenSamlLogoutResponseValidator implements Saml2LogoutResponseValidator { + private final Saml2LogoutResponseValidator delegate = new OpenSamlLogoutResponseValidator(); + + @Override + public Saml2LogoutValidatorResult logout(Saml2LogoutResponseValidatorParameters parameters) { + // verify signature, issuer, destination, and status + Saml2LogoutValidatorResult result = delegate.authenticate(parameters); + LogoutResponse logoutResponse = // ... parse using OpenSAML // perform custom validation - } + } } ---- + +Then, you can supply your custom `Saml2LogoutResponseValidator` in the DSL as follows: + +[source,java] +---- +http + .saml2Logout((saml2) -> saml2 + .logoutResponse((response) -> response + .logoutResponseAuthenticator(myOpenSamlLogoutResponseAuthenticator) + ) + ); +---- + +=== Customizing `` storage + +When your application sends a ``, the value is stored in the session so that the `RelayState` parameter and the `InResponseTo` attribute in the `` can be verified. + +If you want to store logout requests in some place other than the session, you can supply your custom implementation in the DSL, like so: + +[source,java] +---- +http + .saml2Logout((saml2) -> saml2 + .logoutRequest((request) -> request + .logoutRequestRepository(myCustomLogoutRequestRepository) + ) + ); +----