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 33e7c5ad35..9092346b2b 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 c5cfda1a47..b7f4d9c799 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 0000000000..69df68246a --- /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 0000000000..5dd903a066 --- /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 0000000000..ae3e8cb6a4 --- /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 0000000000..17b934eba2 --- /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 0000000000..bbcde5443a --- /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 0000000000..c839ca449e --- /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 0000000000..2f212c9b9e --- /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 0000000000..d7c6e59264 --- /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 0000000000..b052d2c583 --- /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 0000000000..16434be6d6 --- /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 0000000000..0190a85dfb --- /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 edcd9c35c6..1f0d5c19af 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.AssertionConsumerService; 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 @@ public final class OpenSamlMetadataResolver implements Saml2MetadataResolver { spSsoDescriptor.getKeyDescriptors() .addAll(buildKeys(registration.getDecryptionX509Credentials(), UsageType.ENCRYPTION)); spSsoDescriptor.getAssertionConsumerServices().add(buildAssertionConsumerService(registration)); + spSsoDescriptor.getSingleLogoutServices().add(buildSingleLogoutService(registration)); return spSsoDescriptor; } @@ -123,6 +125,14 @@ public final class OpenSamlMetadataResolver implements Saml2MetadataResolver { 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 c7f04d90f4..1b0eb0e35a 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.EntityDescriptor; 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 @@ class OpenSamlAssertingPartyMetadataConverter { 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 @@ class OpenSamlAssertingPartyMetadataConverter { builder.assertingPartyDetails( (party) -> party.singleSignOnServiceLocation(singleSignOnService.getLocation()) .singleSignOnServiceBinding(binding)); - return builder; + break; } - throw new Saml2Exception( - "Metadata response is missing a SingleSignOnService, necessary for sending AuthnRequests"); + 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; + } + 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 80db35d131..d07a3664f8 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 @@ public final class RelyingPartyRegistration { 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 final class RelyingPartyRegistration { 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 final class RelyingPartyRegistration { .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 final class RelyingPartyRegistration { .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 final class RelyingPartyRegistration { 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 @@ public final class RelyingPartyRegistration { this.encryptionX509Credentials = encryptionX509Credentials; this.singleSignOnServiceLocation = singleSignOnServiceLocation; this.singleSignOnServiceBinding = singleSignOnServiceBinding; + this.singleLogoutServiceLocation = singleLogoutServiceLocation; + this.singleLogoutServiceResponseLocation = singleLogoutServiceResponseLocation; + this.singleLogoutServiceBinding = singleLogoutServiceBinding; } /** @@ -565,6 +641,51 @@ public final class RelyingPartyRegistration { 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 final class RelyingPartyRegistration { 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 final class RelyingPartyRegistration { 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 final class RelyingPartyRegistration { 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 final class RelyingPartyRegistration { 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 final class RelyingPartyRegistration { 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 final class RelyingPartyRegistration { 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 ce8ae7e448..cc5f77c318 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 @@ import org.springframework.web.util.UriComponentsBuilder; * @since 5.4 */ public final class DefaultRelyingPartyRegistrationResolver - implements RelyingPartyRegistrationResolver, Converter { + implements Converter, RelyingPartyRegistrationResolver { private Log logger = LogFactory.getLog(getClass()); @@ -98,9 +98,14 @@ public final class DefaultRelyingPartyRegistrationResolver 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 @@ public final class DefaultRelyingPartyRegistrationResolver } 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 0000000000..280d175bda --- /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 0000000000..badbf548fb --- /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 0000000000..bca2affad9 --- /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 0000000000..12ad6769b1 --- /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 0000000000..ab568a55fd --- /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 0000000000..f977ce84b8 --- /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 0000000000..d4b5e4e3b2 --- /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 0000000000..f15ab32924 --- /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 0000000000..a47b39f8eb --- /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 0000000000..5e367714a9 --- /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 0000000000..fc0c71aad8 --- /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 0000000000..7469c0a778 --- /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 0000000000..aded12b428 --- /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 0000000000..99e5d225b1 --- /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 0000000000..89d0bc6a5e --- /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 0000000000..13409e4cdb --- /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 0000000000..e90b2b177c --- /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 0000000000..6ea35b4716 --- /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 0000000000..fd1b21c3ec --- /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 d7a1fcdc34..7acb769f4a 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.EncryptedAssertion; 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.SubjectConfirmation; 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.Saml2Exception; 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 @@ public final class TestOpenSamlObjects { 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 @@ public final class TestOpenSamlObjects { 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 0000000000..0a03299336 --- /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 0000000000..7c2e8d1f28 --- /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 0000000000..ba6481badb --- /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 cef7516c4b..d42fc875be 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 class OpenSamlMetadataResolverTests { .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 class OpenSamlMetadataResolverTests { .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 7d105aecf3..c5626821fc 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 final class TestRelyingPartyRegistrations { 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 3b786cdef0..753f4f62c4 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 class DefaultRelyingPartyRegistrationResolverTests { .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 0000000000..e051edf228 --- /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 0000000000..5f29141218 --- /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 0000000000..1958295c1a --- /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 0000000000..e4438b244c --- /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 0000000000..da4c7dba90 --- /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 0000000000..5d63334b39 --- /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<>()); + } + +}