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 be2fd9ba05..4a23a6f127 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 @@ -1053,20 +1053,279 @@ filter.setRequestMatcher(new 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: + +[source,java] +---- +Saml2LogoutResponseFilter filter = new Saml2LogoutResponseFilter(logoutHandler); +filter.setLogoutRequestMatcher(new AntPathRequestMatcher("/SLOService.saml2", "GET")); http // ... - .logout(logout -> logout - .logoutSuccessHandler(myCustomSuccessHandler()) - .logoutRequestMatcher(myRequestMatcher()) - ) + .addFilterBefore(filter, CsrfFilter.class); ---- -The success handler will send logout requests to the asserting party. +=== Customizing `` Resolution -The request matcher will detect logout requests from the asserting party. +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] +---- +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 + } +} +---- + +=== 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) { + 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/core/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java index c5cfda1a47..be4e3f6c36 100644 --- a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java +++ b/saml2/saml2-service-provider/core/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.5 + */ + String INVALID_REQUEST = "invalid_request"; + /** * Response is invalid in a general way. * diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java new file mode 100644 index 0000000000..535281f273 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutRequest.java @@ -0,0 +1,217 @@ +/* + * 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.5 + */ +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} parameter + * + * 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 + */ + 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 Map parameters = new HashMap<>(); + + private String id; + + private Builder(RelyingPartyRegistration registration) { + this.registration = registration; + } + + /** + * 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 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.registration.getAssertingPartyDetails().getSingleLogoutServiceLocation(), + this.registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(), this.parameters, + this.id, this.registration.getRegistrationId()); + } + + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java new file mode 100644 index 0000000000..65e9e12646 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/Saml2LogoutResponse.java @@ -0,0 +1,184 @@ +/* + * 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.5 + */ +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 + */ + public Map getParameters() { + return this.parameters; + } + + /** + * Create a {@link Saml2LogoutResponse.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 Saml2LogoutResponse.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 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/core/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java index edcd9c35c6..1f0d5c19af 100644 --- a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolver.java +++ b/saml2/saml2-service-provider/core/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/core/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java index c7f04d90f4..1b0eb0e35a 100644 --- a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlAssertingPartyMetadataConverter.java +++ b/saml2/saml2-service-provider/core/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/core/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java index 320bfe35b5..e43ac6f3a7 100644 --- a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java +++ b/saml2/saml2-service-provider/core/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,51 @@ 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.5 + */ + 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.5 + */ + 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.5 + */ + public String getSingleLogoutServiceResponseLocation() { + return this.singleLogoutServiceResponseLocation; + } + /** * Get the {@link Collection} of decryption {@link Saml2X509Credential}s associated * with this relying party @@ -364,6 +420,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 +435,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 +510,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 +544,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 +640,48 @@ 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 + */ + public String getSingleLogoutServiceLocation() { + return this.singleLogoutServiceLocation; + } + + /** + * Get the SingleLogoutService + * ResponseLocation. + * + *

+ * Equivalent to the value found in <SingleLogoutService Location="..."/> in + * the asserting party's <IDPSSODescriptor>. + * @return the SingleLogoutService Response Location + */ + 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 + */ + public Saml2MessageBinding getSingleLogoutServiceBinding() { + return this.singleLogoutServiceBinding; + } + public static final class Builder { private String entityId; @@ -581,6 +698,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 +800,55 @@ public final class RelyingPartyRegistration { return this; } + /** + * Set the SingleLogoutService + * Location. + * + *

+ * Equivalent to the value found in <SingleLogoutService + * Location="..."/> in the asserting party's <IDPSSODescriptor>. + * @return the SingleLogoutService Location + * @since 5.5 + */ + 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>. + * @return the SingleLogoutService Response Location + * @since 5.5 + */ + 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>. + * @return the SingleLogoutService Binding + * @since 5.5 + */ + 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 +861,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 +1004,12 @@ public final class RelyingPartyRegistration { private Saml2MessageBinding assertionConsumerServiceBinding = Saml2MessageBinding.POST; + private String singleLogoutServiceLocation; + + private String singleLogoutServiceResponseLocation; + + private Saml2MessageBinding singleLogoutServiceBinding = Saml2MessageBinding.POST; + private ProviderDetails.Builder providerDetails = new ProviderDetails.Builder(); private Collection credentials = new HashSet<>(); @@ -933,6 +1113,54 @@ public final class RelyingPartyRegistration { return this; } + /** + * Set the SingleLogoutService + * Binding. + * + *

+ * Equivalent to the value found in <SingleLogoutService Binding="..."/> in + * the relying party's <SPSSODescriptor>. + * @return the SingleLogoutService Binding + * @since 5.5 + */ + 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>. + * @return the SingleLogoutService Location + * @since 5.5 + */ + 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>. + * @return the SingleLogoutService Response Location + * @since 5.5 + */ + 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 +1303,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/core/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java index 052a4e82cb..f6f61b53ee 100644 --- a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolver.java @@ -41,8 +41,7 @@ import org.springframework.web.util.UriComponentsBuilder; * @author Josh Cummings * @since 5.4 */ -public final class DefaultRelyingPartyRegistrationResolver - implements Converter, RelyingPartyRegistrationResolver { +public final class DefaultRelyingPartyRegistrationResolver implements RelyingPartyRegistrationResolver { private static final char PATH_DELIMITER = '/'; @@ -56,14 +55,6 @@ public final class DefaultRelyingPartyRegistrationResolver this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository; } - /** - * {@inheritDoc} - */ - @Override - public RelyingPartyRegistration convert(HttpServletRequest request) { - return resolve(request, null); - } - /** * {@inheritDoc} */ @@ -86,9 +77,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) { @@ -96,6 +92,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/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java new file mode 100644 index 0000000000..69a4ef370b --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepository.java @@ -0,0 +1,112 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +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.5 + * @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"); + String stateParameter = this.getStateParameter(request); + if (stateParameter == null) { + return null; + } + Map logoutRequests = this.getLogoutRequests(request); + return logoutRequests.get(stateParameter); + } + + /** + * {@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) { + removeLogoutRequest(request, response); + return; + } + String state = logoutRequest.getRelayState(); + Assert.hasText(state, "logoutRequest.state cannot be empty"); + Map logoutRequests = this.getLogoutRequests(request); + logoutRequests.put(state, logoutRequest); + request.getSession().setAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME, logoutRequests); + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutRequest removeLogoutRequest(HttpServletRequest request, HttpServletResponse response) { + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); + String stateParameter = getStateParameter(request); + if (stateParameter == null) { + return null; + } + Map logoutRequests = getLogoutRequests(request); + Saml2LogoutRequest originalRequest = logoutRequests.remove(stateParameter); + if (!logoutRequests.isEmpty()) { + request.getSession().setAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME, logoutRequests); + } + else { + request.getSession().removeAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME); + } + return originalRequest; + } + + private String getStateParameter(HttpServletRequest request) { + return request.getParameter("RelayState"); + } + + private Map getLogoutRequests(HttpServletRequest request) { + HttpSession session = request.getSession(false); + Map logoutRequests = (session != null) + ? (Map) session.getAttribute(DEFAULT_LOGOUT_REQUEST_ATTR_NAME) : null; + if (logoutRequests == null) { + return new HashMap<>(); + } + return logoutRequests; + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandler.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandler.java new file mode 100644 index 0000000000..3b8ae4a43f --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandler.java @@ -0,0 +1,198 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +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.http.HttpMethod; +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.core.Saml2ResponseValidatorResult; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlVerificationUtils.VerifierPartial; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.util.Assert; + +/** + * A {@link LogoutHandler} that handles SAML 2.0 Logout Requests received from a SAML 2.0 + * Asserting Party. + * + * @author Josh Cummings + * @since 5.5 + */ +public final class OpenSamlLogoutRequestHandler implements LogoutHandler { + + static { + OpenSamlInitializationService.initialize(); + } + + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; + + private final ParserPool parserPool; + + private final LogoutRequestUnmarshaller unmarshaller; + + /** + * Constructs a {@link OpenSamlLogoutRequestHandler} from the provided parameters + * @param relyingPartyRegistrationResolver the + * {@link RelyingPartyRegistrationResolver} from which to derive the + * {@link RelyingPartyRegistration} + */ + public OpenSamlLogoutRequestHandler(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); + } + + /** + * Processes 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. + * + * If any processing step fails, a {@link Saml2Exception} is thrown, stopping the + * logout process + * @param request the HTTP request + * @param response the HTTP response + * @param authentication the current principal details + */ + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + String serialized = request.getParameter("SAMLRequest"); + Assert.notNull(serialized, "SAMLRequest cannot be null"); + byte[] b = Saml2Utils.samlDecode(serialized); + serialized = inflateIfRequired(request, b); + RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, + getRegistrationId(authentication)); + Assert.notNull(registration, "Failed to lookup RelyingPartyRegistration for request"); + LogoutRequest logoutRequest = parse(serialized); + Saml2ResponseValidatorResult result = verifySignature(request, logoutRequest, registration); + result = result.concat(validateRequest(logoutRequest, registration, authentication)); + if (result.hasErrors()) { + throw new Saml2Exception("Failed to validate LogoutRequest: " + result.getErrors().iterator().next()); + } + request.setAttribute(LogoutRequest.class.getName(), logoutRequest); + } + + private String getRegistrationId(Authentication authentication) { + if (authentication instanceof Saml2Authentication) { + return ((Saml2Authentication) authentication).getRelyingPartyRegistrationId(); + } + return null; + } + + private String inflateIfRequired(HttpServletRequest request, byte[] b) { + if (HttpMethod.GET.matches(request.getMethod())) { + 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 Saml2ResponseValidatorResult verifySignature(HttpServletRequest request, LogoutRequest logoutRequest, + RelyingPartyRegistration registration) { + VerifierPartial partial = OpenSamlVerificationUtils.verifySignature(logoutRequest, registration); + if (logoutRequest.isSigned()) { + return partial.post(logoutRequest.getSignature()); + } + return partial.redirect(request, "SAMLRequest"); + } + + private Saml2ResponseValidatorResult validateRequest(LogoutRequest request, RelyingPartyRegistration registration, + Authentication authentication) { + Saml2ResponseValidatorResult result = Saml2ResponseValidatorResult.success(); + return result.concat(validateIssuer(request, registration)).concat(validateDestination(request, registration)) + .concat(validateName(request, authentication)); + } + + private Saml2ResponseValidatorResult validateIssuer(LogoutRequest request, RelyingPartyRegistration registration) { + if (request.getIssuer() == null) { + return Saml2ResponseValidatorResult + .failure(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse")); + } + String issuer = request.getIssuer().getValue(); + if (!issuer.equals(registration.getAssertingPartyDetails().getEntityId())) { + return Saml2ResponseValidatorResult.failure( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); + } + return Saml2ResponseValidatorResult.success(); + } + + private Saml2ResponseValidatorResult validateDestination(LogoutRequest request, + RelyingPartyRegistration registration) { + if (request.getDestination() == null) { + return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to find destination in LogoutResponse")); + } + String destination = request.getDestination(); + if (!destination.equals(registration.getSingleLogoutServiceLocation())) { + return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to match destination to configured destination")); + } + return Saml2ResponseValidatorResult.success(); + } + + private Saml2ResponseValidatorResult validateName(LogoutRequest request, Authentication authentication) { + if (authentication == null) { + return Saml2ResponseValidatorResult.success(); + } + NameID nameId = request.getNameID(); + if (nameId == null) { + return Saml2ResponseValidatorResult.failure( + new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, "Failed to find subject in LogoutRequest")); + } + String name = nameId.getValue(); + if (!name.equals(authentication.getName())) { + return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, + "Failed to match subject in LogoutRequest with currently logged in user")); + } + return Saml2ResponseValidatorResult.success(); + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java new file mode 100644 index 0000000000..43135b36c9 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolver.java @@ -0,0 +1,243 @@ +/* + * 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.Map; +import java.util.UUID; +import java.util.function.Consumer; + +import javax.servlet.http.HttpServletRequest; + +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +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.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.web.RelyingPartyRegistrationResolver; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with + * OpenSAML + * + * Note that there are {@link Saml2LogoutRequestResolver} implements that are targeted for + * OpenSAML 3 and OpenSAML 4 via {@code OpenSaml3LogoutRequestResolver} and + * {@code OpenSaml4LogoutRequestResolver} + * + * @author Josh Cummings + * @since 5.5 + */ +public final class OpenSamlLogoutRequestResolver implements Saml2LogoutRequestResolver { + + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; + + /** + * Construct a {@link OpenSamlLogoutRequestResolver} using the provided parameters + * @param relyingPartyRegistrationResolver the + * {@link RelyingPartyRegistrationResolver} for selecting the + * {@link RelyingPartyRegistration} + */ + public OpenSamlLogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; + } + + /** + * 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}. + * + * The {@link Authentication} must be of type {@link Saml2Authentication} in order to + * look up the {@link RelyingPartyRegistration} that holds the signing key. + * @param request the HTTP request + * @param authentication the current principal details + * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request + * that the resolver supplied + */ + @Override + public OpenSamlLogoutRequestBuilder resolveLogoutRequest(HttpServletRequest request, + Authentication authentication) { + Assert.notNull(authentication, "Failed to lookup logged-in user for formulating LogoutRequest"); + RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, + getRegistrationId(authentication)); + Assert.notNull(registration, "Failed to lookup RelyingPartyRegistration for formulating LogoutRequest"); + return new OpenSamlLogoutRequestBuilder(registration) + .destination(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation()) + .issuer(registration.getEntityId()).name(authentication.getName()); + } + + private String getRegistrationId(Authentication authentication) { + if (authentication instanceof Saml2Authentication) { + return ((Saml2Authentication) authentication).getRelyingPartyRegistrationId(); + } + return null; + } + + /** + * A builder, useful for overriding any aspects of the SAML 2.0 Logout Request that + * the resolver supplied. + * + * The request returned from the {@link #logoutRequest()} method is signed and + * serialized. It will at minimum include an {@code ID} and a {@code RelayState}, + * though note that callers should also provide an {@code IssueInstant}. For your + * convenience, {@link OpenSamlLogoutRequestResolver} also sets some default values. + * + * This builder is specifically handy for getting access to the underlying + * {@link LogoutRequest} to make changes before it gets signed and serialized + * + * @see OpenSamlLogoutRequestResolver#resolveLogoutRequest + */ + public static final class OpenSamlLogoutRequestBuilder + implements Saml2LogoutRequestBuilder { + + static { + OpenSamlInitializationService.initialize(); + } + + private final LogoutRequestMarshaller marshaller; + + private final IssuerBuilder issuerBuilder; + + private final NameIDBuilder nameIdBuilder; + + private final RelyingPartyRegistration registration; + + private final LogoutRequest logoutRequest; + + private String relayState; + + /** + * Construct a {@link OpenSamlLogoutRequestBuilder} using the provided parameters + * @param registration the {@link RelyingPartyRegistration} to use + */ + public OpenSamlLogoutRequestBuilder(RelyingPartyRegistration registration) { + Assert.notNull(registration, "registration cannot be null"); + this.registration = registration; + 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"); + LogoutRequestBuilder logoutRequestBuilder = (LogoutRequestBuilder) registry.getBuilderFactory() + .getBuilder(LogoutRequest.DEFAULT_ELEMENT_NAME); + Assert.notNull(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"); + this.logoutRequest = logoutRequestBuilder.buildObject(); + } + + /** + * {@inheritDoc} + */ + @Override + public OpenSamlLogoutRequestBuilder name(String name) { + NameID nameId = this.nameIdBuilder.buildObject(); + nameId.setValue(name); + this.logoutRequest.setNameID(nameId); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public OpenSamlLogoutRequestBuilder relayState(String relayState) { + this.relayState = relayState; + return this; + } + + /** + * Mutate the {@link LogoutRequest} using the provided {@link Consumer} + * @param request the Logout Request {@link Consumer} to use + * @return the {@link OpenSamlLogoutRequestBuilder} for further customizations + */ + public OpenSamlLogoutRequestBuilder logoutRequest(Consumer request) { + request.accept(this.logoutRequest); + return this; + } + + private OpenSamlLogoutRequestBuilder destination(String destination) { + this.logoutRequest.setDestination(destination); + return this; + } + + private OpenSamlLogoutRequestBuilder issuer(String issuer) { + Issuer iss = this.issuerBuilder.buildObject(); + iss.setValue(issuer); + this.logoutRequest.setIssuer(iss); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutRequest logoutRequest() { + if (this.logoutRequest.getID() == null) { + this.logoutRequest.setID("LR" + UUID.randomUUID()); + } + if (this.relayState == null) { + this.relayState = UUID.randomUUID().toString(); + } + Saml2LogoutRequest.Builder result = Saml2LogoutRequest.withRelyingPartyRegistration(this.registration) + .id(this.logoutRequest.getID()); + if (this.registration.getAssertingPartyDetails() + .getSingleLogoutServiceBinding() == Saml2MessageBinding.POST) { + String xml = serialize(OpenSamlSigningUtils.sign(this.logoutRequest, this.registration)); + return result.samlRequest(Saml2Utils.samlEncode(xml.getBytes(StandardCharsets.UTF_8))).build(); + } + else { + String xml = serialize(this.logoutRequest); + String deflatedAndEncoded = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(xml)); + result.samlRequest(deflatedAndEncoded); + Map parameters = OpenSamlSigningUtils.sign(this.registration) + .param("SAMLRequest", deflatedAndEncoded).param("RelayState", this.relayState).parameters(); + return result.parameters((params) -> params.putAll(parameters)).build(); + } + } + + 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/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandler.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandler.java new file mode 100644 index 0000000000..896849dab4 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandler.java @@ -0,0 +1,217 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +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.http.HttpMethod; +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.core.Saml2ResponseValidatorResult; +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.security.saml2.provider.service.web.authentication.logout.OpenSamlVerificationUtils.VerifierPartial; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.util.Assert; + +/** + * A {@link LogoutHandler} that handles SAML 2.0 Logout Responses received from a SAML 2.0 + * Asserting Party. + * + * @author Josh Cummings + * @since 5.5 + */ +public final class OpenSamlLogoutResponseHandler implements LogoutHandler { + + static { + OpenSamlInitializationService.initialize(); + } + + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; + + private final ParserPool parserPool; + + private final LogoutResponseUnmarshaller unmarshaller; + + private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository(); + + /** + * Constructs a {@link OpenSamlLogoutResponseHandler} from the provided parameters + * @param relyingPartyRegistrationResolver the + * {@link RelyingPartyRegistrationResolver} from which to derive the + * {@link RelyingPartyRegistration} + */ + public OpenSamlLogoutResponseHandler(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; + XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class); + this.parserPool = registry.getParserPool(); + this.unmarshaller = (LogoutResponseUnmarshaller) XMLObjectProviderRegistrySupport.getUnmarshallerFactory() + .getUnmarshaller(LogoutResponse.DEFAULT_ELEMENT_NAME); + } + + /** + * Processes 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. + * + * If any processing step fails, a {@link Saml2Exception} is thrown, stopping the + * logout process + * @param request the HTTP request + * @param response the HTTP response + * @param authentication the current principal details + */ + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + String serialized = request.getParameter("SAMLResponse"); + Assert.notNull(serialized, "SAMLResponse cannot be null"); + byte[] b = Saml2Utils.samlDecode(serialized); + serialized = inflateIfRequired(request, b); + Saml2LogoutRequest logoutRequest = this.logoutRequestRepository.removeLogoutRequest(request, response); + if (logoutRequest == null) { + throw new Saml2Exception("Failed to find associated LogoutRequest"); + } + RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, + logoutRequest.getRelyingPartyRegistrationId()); + LogoutResponse logoutResponse = parse(serialized); + Saml2ResponseValidatorResult result = verifySignature(request, logoutResponse, registration) + .concat(validateRequest(logoutResponse, registration)) + .concat(validateLogoutRequest(logoutResponse, logoutRequest.getId())); + if (result.hasErrors()) { + throw new Saml2Exception("Failed to validate LogoutResponse: " + result.getErrors().iterator().next()); + } + } + + /** + * Use this {@link Saml2LogoutRequestRepository} for looking up the associated 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 String inflateIfRequired(HttpServletRequest request, byte[] b) { + if (HttpMethod.GET.matches(request.getMethod())) { + 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 Saml2ResponseValidatorResult verifySignature(HttpServletRequest request, LogoutResponse response, + RelyingPartyRegistration registration) { + VerifierPartial partial = OpenSamlVerificationUtils.verifySignature(response, registration); + if (response.isSigned()) { + return partial.post(response.getSignature()); + } + return partial.redirect(request, "SAMLResponse"); + } + + private Saml2ResponseValidatorResult validateRequest(LogoutResponse response, + RelyingPartyRegistration registration) { + Saml2ResponseValidatorResult result = Saml2ResponseValidatorResult.success(); + return result.concat(validateIssuer(response, registration)).concat(validateDestination(response, registration)) + .concat(validateStatus(response)); + } + + private Saml2ResponseValidatorResult validateIssuer(LogoutResponse response, + RelyingPartyRegistration registration) { + if (response.getIssuer() == null) { + return Saml2ResponseValidatorResult + .failure(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to find issuer in LogoutResponse")); + } + String issuer = response.getIssuer().getValue(); + if (!issuer.equals(registration.getAssertingPartyDetails().getEntityId())) { + return Saml2ResponseValidatorResult.failure( + new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, "Failed to match issuer to configured issuer")); + } + return Saml2ResponseValidatorResult.success(); + } + + private Saml2ResponseValidatorResult validateDestination(LogoutResponse response, + RelyingPartyRegistration registration) { + if (response.getDestination() == null) { + return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to find destination in LogoutResponse")); + } + String destination = response.getDestination(); + if (!destination.equals(registration.getSingleLogoutServiceResponseLocation())) { + return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, + "Failed to match destination to configured destination")); + } + return Saml2ResponseValidatorResult.success(); + } + + private Saml2ResponseValidatorResult validateStatus(LogoutResponse response) { + if (response.getStatus() == null) { + return Saml2ResponseValidatorResult.success(); + } + if (response.getStatus().getStatusCode() == null) { + return Saml2ResponseValidatorResult.success(); + } + if (StatusCode.SUCCESS.equals(response.getStatus().getStatusCode().getValue())) { + return Saml2ResponseValidatorResult.success(); + } + if (StatusCode.PARTIAL_LOGOUT.equals(response.getStatus().getStatusCode().getValue())) { + return Saml2ResponseValidatorResult.success(); + } + return Saml2ResponseValidatorResult + .failure(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, "Response indicated logout failed")); + } + + private Saml2ResponseValidatorResult validateLogoutRequest(LogoutResponse response, String id) { + if (response.getInResponseTo() == null) { + return Saml2ResponseValidatorResult.success(); + } + if (response.getInResponseTo().equals(id)) { + return Saml2ResponseValidatorResult.success(); + } + return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, + "LogoutResponse InResponseTo doesn't match ID of associated LogoutRequest")); + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java new file mode 100644 index 0000000000..496d3377d5 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolver.java @@ -0,0 +1,268 @@ +/* + * 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.Consumer; + +import javax.servlet.http.HttpServletRequest; + +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +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.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.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.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.Saml2Authentication; +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; + +/** + * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Responses with + * OpenSAML + * + * Note that there are {@link Saml2LogoutRequestResolver} implements that are targeted for + * OpenSAML 3 and OpenSAML 4 via {@code OpenSaml3LogoutResponseResolver} and + * {@code OpenSaml4LogoutResponseResolver} + * + * @author Josh Cummings + * @since 5.5 + */ +public final class OpenSamlLogoutResponseResolver implements Saml2LogoutResponseResolver { + + private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver; + + /** + * Construct a {@link OpenSamlLogoutResponseResolver} using the provided parameters + * @param relyingPartyRegistrationResolver the + * {@link RelyingPartyRegistrationResolver} for selecting the + * {@link RelyingPartyRegistration} + */ + public OpenSamlLogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.relyingPartyRegistrationResolver = relyingPartyRegistrationResolver; + } + + /** + * 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}. + * + * The {@link Authentication} must be of type {@link Saml2Authentication} in order to + * look up the {@link RelyingPartyRegistration} that holds the signing key. + * @param request the HTTP request + * @param authentication the current principal details + * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request + * that the resolver supplied + */ + @Override + public OpenSamlLogoutResponseBuilder resolveLogoutResponse(HttpServletRequest request, + Authentication authentication) { + LogoutRequest logoutRequest = (LogoutRequest) request.getAttribute(LogoutRequest.class.getName()); + if (logoutRequest == null) { + throw new Saml2Exception("Failed to find associated LogoutRequest"); + } + RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, + getRegistrationId(authentication)); + Assert.notNull(registration, "Failed to lookup RelyingPartyRegistration for request"); + return new OpenSamlLogoutResponseBuilder(registration) + .destination(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation()) + .issuer(registration.getEntityId()).status(StatusCode.SUCCESS) + .relayState(request.getParameter("RelayState")).inResponseTo(logoutRequest.getID()); + } + + private String getRegistrationId(Authentication authentication) { + if (authentication instanceof Saml2Authentication) { + return ((Saml2Authentication) authentication).getRelyingPartyRegistrationId(); + } + return null; + } + + /** + * A builder, useful for overriding any aspects of the SAML 2.0 Logout Response that + * the resolver supplied. + * + * The request returned from the {@link #logoutResponse()} method is signed and + * serialized. It will at minimum include an {@code ID}, though note that callers + * should include an {@code InResponseTo} and {@code IssueInstant}. For your + * convenience, {@link OpenSamlLogoutResponseResolver} also sets some default values. + * + * This builder is specifically handy for getting access to the underlying + * {@link LogoutResponse} to make changes before it gets signed and serialized + * + * @see OpenSamlLogoutResponseResolver#resolveLogoutResponse + */ + public static final class OpenSamlLogoutResponseBuilder + implements Saml2LogoutResponseBuilder { + + static { + OpenSamlInitializationService.initialize(); + } + + private final LogoutResponseMarshaller marshaller; + + private final LogoutResponseBuilder logoutResponseBuilder; + + private final IssuerBuilder issuerBuilder; + + private final StatusBuilder statusBuilder; + + private final StatusCodeBuilder statusCodeBuilder; + + private final RelyingPartyRegistration registration; + + private final LogoutResponse logoutResponse; + + private String relayState; + + /** + * Construct a {@link OpenSamlLogoutResponseBuilder} using the provided parameters + * @param registration the {@link RelyingPartyRegistration} to use + */ + public OpenSamlLogoutResponseBuilder(RelyingPartyRegistration registration) { + Assert.notNull(registration, "registration cannot be null"); + this.registration = registration; + XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class); + 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"); + this.logoutResponse = this.logoutResponseBuilder.buildObject(); + } + + /** + * {@inheritDoc} + */ + @Override + public OpenSamlLogoutResponseBuilder inResponseTo(String inResponseTo) { + this.logoutResponse.setInResponseTo(inResponseTo); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public OpenSamlLogoutResponseBuilder status(String status) { + StatusCode code = this.statusCodeBuilder.buildObject(); + code.setValue(status); + Status s = this.statusBuilder.buildObject(); + s.setStatusCode(code); + this.logoutResponse.setStatus(s); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public OpenSamlLogoutResponseBuilder relayState(String relayState) { + this.relayState = relayState; + return this; + } + + /** + * Mutate the {@link LogoutResponse} using the provided {@link Consumer} + * @param response the Logout Response {@link Consumer} to use + * @return the {@link OpenSamlLogoutResponseBuilder} for further customizations + */ + public OpenSamlLogoutResponseBuilder logoutResponse(Consumer response) { + response.accept(this.logoutResponse); + return this; + } + + private OpenSamlLogoutResponseBuilder destination(String destination) { + this.logoutResponse.setDestination(destination); + return this; + } + + private OpenSamlLogoutResponseBuilder issuer(String issuer) { + Issuer iss = this.issuerBuilder.buildObject(); + iss.setValue(issuer); + this.logoutResponse.setIssuer(iss); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public Saml2LogoutResponse logoutResponse() { + Saml2LogoutResponse.Builder result = Saml2LogoutResponse.withRelyingPartyRegistration(this.registration); + if (this.logoutResponse.getID() == null) { + this.logoutResponse.setID("LR" + UUID.randomUUID()); + } + if (this.registration.getAssertingPartyDetails() + .getSingleLogoutServiceBinding() == Saml2MessageBinding.POST) { + String xml = serialize(OpenSamlSigningUtils.sign(this.logoutResponse, this.registration)); + return result.samlResponse(Saml2Utils.samlEncode(xml.getBytes(StandardCharsets.UTF_8))).build(); + } + else { + String xml = serialize(this.logoutResponse); + String deflatedAndEncoded = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(xml)); + result.samlResponse(deflatedAndEncoded); + QueryParametersPartial partial = OpenSamlSigningUtils.sign(this.registration).param("SAMLResponse", + deflatedAndEncoded); + if (this.relayState != null) { + partial.param("RelayState", this.relayState); + } + return result.parameters((params) -> params.putAll(partial.parameters())).build(); + } + } + + 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/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlSigningUtils.java b/saml2/saml2-service-provider/core/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/core/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/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlVerificationUtils.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlVerificationUtils.java new file mode 100644 index 0000000000..0555c74657 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlVerificationUtils.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.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +import javax.servlet.http.HttpServletRequest; + +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.Saml2ResponseValidatorResult; +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); + } + + Saml2ResponseValidatorResult redirect(HttpServletRequest request, String objectParameterName) { + RedirectSignature signature = new RedirectSignature(request, objectParameterName); + if (signature.getAlgorithm() == null) { + return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, + "Missing signature algorithm for object [" + this.id + "]")); + } + if (!signature.hasSignature()) { + return Saml2ResponseValidatorResult.failure(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 Saml2ResponseValidatorResult.failure(errors); + } + + Saml2ResponseValidatorResult 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 Saml2ResponseValidatorResult.failure(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 HttpServletRequest request; + + private final String objectParameterName; + + RedirectSignature(HttpServletRequest request, String objectParameterName) { + this.request = request; + this.objectParameterName = objectParameterName; + } + + String getAlgorithm() { + return this.request.getParameter("SigAlg"); + } + + byte[] getContent() { + if (this.request.getParameter("RelayState") != null) { + return String.format("%s=%s&RelayState=%s&SigAlg=%s", this.objectParameterName, + UriUtils.encode(this.request.getParameter(this.objectParameterName), + StandardCharsets.ISO_8859_1), + UriUtils.encode(this.request.getParameter("RelayState"), StandardCharsets.ISO_8859_1), + UriUtils.encode(getAlgorithm(), StandardCharsets.ISO_8859_1)) + .getBytes(StandardCharsets.UTF_8); + } + else { + return String + .format("%s=%s&SigAlg=%s", this.objectParameterName, + UriUtils.encode(this.request.getParameter(this.objectParameterName), + StandardCharsets.ISO_8859_1), + UriUtils.encode(getAlgorithm(), StandardCharsets.ISO_8859_1)) + .getBytes(StandardCharsets.UTF_8); + } + } + + byte[] getSignature() { + return Saml2Utils.samlDecode(this.request.getParameter("Signature")); + } + + boolean hasSignature() { + return this.request.getParameter("Signature") != null; + } + + } + + } + + private OpenSamlVerificationUtils() { + + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java new file mode 100644 index 0000000000..43cc633007 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java @@ -0,0 +1,92 @@ +/* + * 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.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.LogoutHandler; +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 logout requests in the form of a <saml2:LogoutRequest> sent + * from the asserting party. + * + * @author Josh Cummings + * @since 5.5 + */ +public final class Saml2LogoutRequestFilter extends OncePerRequestFilter { + + private static final String DEFAULT_LOGOUT_ENDPOINT = "/logout/saml2/slo"; + + private RequestMatcher logoutRequestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_ENDPOINT); + + private final LogoutHandler logoutHandler; + + private final LogoutSuccessHandler logoutSuccessHandler; + + /** + * Constructs a {@link Saml2LogoutResponseFilter} for accepting SAML 2.0 Logout + * Requests from the asserting party + * @param logoutSuccessHandler the success handler to be run after the logout request + * passes validation and other logout operations succeed. This success handler will + * typically be one that issues a SAML 2.0 Logout Response to the asserting party, + * like {@link Saml2LogoutResponseSuccessHandler} + * @param logoutHandler the handler for handling the logout request, may be a + * {@link org.springframework.security.web.authentication.logout.CompositeLogoutHandler} + * that handles other logout concerns + */ + public Saml2LogoutRequestFilter(LogoutSuccessHandler logoutSuccessHandler, LogoutHandler logoutHandler) { + this.logoutSuccessHandler = logoutSuccessHandler; + this.logoutHandler = logoutHandler; + } + + @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(); + this.logoutHandler.logout(request, response, authentication); + this.logoutSuccessHandler.onLogoutSuccess(request, response, authentication); + } + + public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) { + Assert.notNull(logoutRequestMatcher, "logoutRequestMatcher cannot be null"); + this.logoutRequestMatcher = logoutRequestMatcher; + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestRepository.java new file mode 100644 index 0000000000..9b9b1c5ad4 --- /dev/null +++ b/saml2/saml2-service-provider/core/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 Saml2LogoutRequestSuccessHandler} for persisting the Logout Request + * before it initiates the SAML 2.0 SLO flow. As well, used by + * {@link OpenSamlLogoutResponseHandler} for resolving the Logout Request associated with + * that Logout Response. + * + * @author Josh Cummings + * @since 5.5 + * @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/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java new file mode 100644 index 0000000000..cc3258b8c2 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestResolver.java @@ -0,0 +1,82 @@ +/* + * 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.5 + * @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 principal details + * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request + * that the resolver supplied + */ + Saml2LogoutRequestBuilder resolveLogoutRequest(HttpServletRequest request, Authentication authentication); + + /** + * A partial application, useful for overriding any aspects of the SAML 2.0 Logout + * Request that the resolver supplied. + * + * The request returned from the {@link #logoutRequest()} method is signed and + * serialized + */ + interface Saml2LogoutRequestBuilder

> { + + /** + * Use the given name in the SAML 2.0 Logout Request + * @param name the name to use + * @return the {@link Saml2LogoutRequestBuilder} for further customizations + */ + P name(String name); + + /** + * Use this relay state when sending the logout response + * @param relayState the relay state to use + * @return the {@link Saml2LogoutRequestBuilder} for further customizations + */ + P relayState(String relayState); + + /** + * Return a signed and serialized SAML 2.0 Logout Request and associated signed + * request parameters + * @return a signed and serialized SAML 2.0 Logout Request + */ + Saml2LogoutRequest logoutRequest(); + + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandler.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandler.java new file mode 100644 index 0000000000..be851211e7 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandler.java @@ -0,0 +1,159 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +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 Response in response to the SAML 2.0 + * Logout Request that the SAML 2.0 Asserting Party sent + * + * @author Josh Cummings + * @since 5.5 + */ +public final class Saml2LogoutRequestSuccessHandler implements LogoutSuccessHandler { + + private final Saml2LogoutRequestResolver logoutRequestResolver; + + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private Saml2LogoutRequestRepository logoutRequestRepository = new HttpSessionLogoutRequestRepository(); + + /** + * Constructs a {@link Saml2LogoutRequestSuccessHandler} using the provided parameters + * @param logoutRequestResolver the {@link Saml2LogoutRequestResolver} to use + */ + public Saml2LogoutRequestSuccessHandler(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 { + Saml2LogoutRequestResolver.Saml2LogoutRequestBuilder builder = this.logoutRequestResolver + .resolveLogoutRequest(request, authentication); + if (builder == null) { + return; + } + Saml2LogoutRequest logoutRequest = builder.logoutRequest(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest, request, response); + if (logoutRequest.getBinding() == Saml2MessageBinding.REDIRECT) { + doRedirect(request, response, logoutRequest); + } + else { + doPost(response, logoutRequest); + } + } + + 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, uriBuilder); + addParameter("RelayState", logoutRequest, uriBuilder); + addParameter("SigAlg", logoutRequest, uriBuilder); + addParameter("Signature", logoutRequest, uriBuilder); + this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString()); + } + + private void addParameter(String name, Saml2LogoutRequest logoutRequest, UriComponentsBuilder builder) { + Assert.hasText(name, "name cannot be empty or null"); + if (StringUtils.hasText(logoutRequest.getParameter(name))) { + builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1), + UriUtils.encode(logoutRequest.getParameter(name), StandardCharsets.ISO_8859_1)); + } + } + + private void doPost(HttpServletResponse response, Saml2LogoutRequest logoutRequest) throws IOException { + String html = createSamlPostRequestFormData(logoutRequest); + response.setContentType(MediaType.TEXT_HTML_VALUE); + response.getWriter().write(html); + } + + private String createSamlPostRequestFormData(Saml2LogoutRequest logoutRequest) { + String location = logoutRequest.getLocation(); + String samlRequest = logoutRequest.getSamlRequest(); + String relayState = logoutRequest.getRelayState(); + 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/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java new file mode 100644 index 0000000000..5fedb0c7a4 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilter.java @@ -0,0 +1,117 @@ +/* + * 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.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.CompositeLogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; +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 LogoutHandler} or + * {@link LogoutSuccessHandler} that rely on the user being logged in. + * + * @author Josh Cummings + * @since 5.5 + */ +public final class Saml2LogoutResponseFilter extends OncePerRequestFilter { + + private static final String DEFAULT_LOGOUT_ENDPOINT = "/logout/saml2/slo"; + + private RequestMatcher logoutRequestMatcher = new AntPathRequestMatcher(DEFAULT_LOGOUT_ENDPOINT); + + private final LogoutHandler logoutHandler; + + private LogoutSuccessHandler logoutSuccessHandler = new SimpleUrlLogoutSuccessHandler(); + + /** + * Constructs a {@link Saml2LogoutResponseFilter} for accepting SAML 2.0 Logout + * Responses from the asserting party + * @param logoutHandler the handlers for handling the logout response + */ + public Saml2LogoutResponseFilter(LogoutHandler logoutHandler) { + this.logoutHandler = new CompositeLogoutHandler(logoutHandler); + } + + /** + * {@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; + } + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + this.logoutHandler.logout(request, response, authentication); + this.logoutSuccessHandler.onLogoutSuccess(request, response, authentication); + } + + /** + * Use this {@link RequestMatcher} for requests + * + * This is handy when your asserting party needs it to be a specific endpoint instead + * of the default. + * @param logoutRequestMatcher the {@link RequestMatcher} to use + */ + public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) { + Assert.notNull(logoutRequestMatcher, "logoutRequestMatcher cannot be null"); + this.logoutRequestMatcher = logoutRequestMatcher; + } + + /** + * Use this {@link LogoutSuccessHandler} when complete + * + * Note that when a <saml2:LogoutResponse> is received, the end user is already + * logged out. Any {@link LogoutSuccessHandler} used here should not rely on the + * {@link Authentication}. {@link SimpleUrlLogoutSuccessHandler} is an example of + * this. + * @param logoutSuccessHandler the {@link LogoutSuccessHandler} to use + * @see SimpleUrlLogoutSuccessHandler + */ + public void setLogoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler) { + Assert.notNull(logoutSuccessHandler, "logoutSuccessHandler cannot be null"); + this.logoutSuccessHandler = logoutSuccessHandler; + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java new file mode 100644 index 0000000000..bfd1fb7490 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseResolver.java @@ -0,0 +1,90 @@ +/* + * 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.5 + * @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 principal details + * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout + * Response that the resolver supplied + */ + Saml2LogoutResponseBuilder resolveLogoutResponse(HttpServletRequest request, Authentication authentication); + + /** + * A partial application, useful for overriding any aspects of the SAML 2.0 Logout + * Response that the resolver supplied. + * + * The response returned from the {@link #logoutResponse()} method is signed and + * serialized + */ + interface Saml2LogoutResponseBuilder

> { + + /** + * Use this value as the {@code InResponseTo} identifier for the associated SAML + * 2.0 Logout Request + * @param name the logout request identifier + * @return the {@link Saml2LogoutResponseBuilder} for further customizations + */ + P inResponseTo(String name); + + /** + * Use this status code in the logout response. + * + * The default is {@code SUCCESS}. + * @param status the status code to use + * @return the {@link Saml2LogoutResponseBuilder} for further customizations + */ + P status(String status); + + /** + * Use this relay state when sending the logout response + * @param relayState the relay state to use + * @return the {@link Saml2LogoutResponseBuilder} for further customizations + */ + P relayState(String relayState); + + /** + * Return a signed and serialized SAML 2.0 Logout Response and associated signed + * request parameters + * @return a signed and serialized SAML 2.0 Logout Response + */ + Saml2LogoutResponse logoutResponse(); + + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandler.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandler.java new file mode 100644 index 0000000000..48586e1814 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandler.java @@ -0,0 +1,148 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; +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 Response in response to the SAML 2.0 + * Logout Request that the SAML 2.0 Asserting Party sent + * + * @author Josh Cummings + * @since 5.5 + */ +public final class Saml2LogoutResponseSuccessHandler implements LogoutSuccessHandler { + + private final Saml2LogoutResponseResolver logoutResponseResolver; + + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + /** + * Constructs a {@link Saml2LogoutResponseSuccessHandler} using the provided + * parameters + * @param logoutResponseResolver the {@link Saml2LogoutResponseResolver} to use + */ + public Saml2LogoutResponseSuccessHandler(Saml2LogoutResponseResolver logoutResponseResolver) { + this.logoutResponseResolver = logoutResponseResolver; + } + + /** + * 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 { + Saml2LogoutResponse logoutResponse = this.logoutResponseResolver.resolveLogoutResponse(request, authentication) + .logoutResponse(); + if (logoutResponse.getBinding() == Saml2MessageBinding.REDIRECT) { + doRedirect(request, response, logoutResponse); + } + else { + doPost(response, logoutResponse); + } + } + + private void doRedirect(HttpServletRequest request, HttpServletResponse response, + Saml2LogoutResponse logoutResponse) throws IOException { + String location = logoutResponse.getResponseLocation(); + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(location); + addParameter("SAMLResponse", logoutResponse, uriBuilder); + addParameter("RelayState", logoutResponse, uriBuilder); + addParameter("SigAlg", logoutResponse, uriBuilder); + addParameter("Signature", logoutResponse, uriBuilder); + this.redirectStrategy.sendRedirect(request, response, uriBuilder.build(true).toUriString()); + } + + private void addParameter(String name, Saml2LogoutResponse logoutResponse, UriComponentsBuilder builder) { + Assert.hasText(name, "name cannot be empty or null"); + if (StringUtils.hasText(logoutResponse.getParameter(name))) { + builder.queryParam(UriUtils.encode(name, StandardCharsets.ISO_8859_1), + UriUtils.encode(logoutResponse.getParameter(name), StandardCharsets.ISO_8859_1)); + } + } + + private void doPost(HttpServletResponse response, Saml2LogoutResponse logoutResponse) throws IOException { + String html = createSamlPostRequestFormData(logoutResponse); + response.setContentType(MediaType.TEXT_HTML_VALUE); + response.getWriter().write(html); + } + + private String createSamlPostRequestFormData(Saml2LogoutResponse logoutResponse) { + String location = logoutResponse.getResponseLocation(); + String samlRequest = logoutResponse.getSamlResponse(); + String relayState = logoutResponse.getRelayState(); + 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/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RequestAttributeNames.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RequestAttributeNames.java new file mode 100644 index 0000000000..405401af54 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2RequestAttributeNames.java @@ -0,0 +1,35 @@ +/* + * 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; + +/** + * Attribute names for coordinating between SAML 2.0 Logout components. + * + * For internal use only. + * + * @author Josh Cummings + */ + +final class Saml2RequestAttributeNames { + + static final String LOGOUT_REQUEST_ID = Saml2RequestAttributeNames.class.getName() + "_LOGOUT_REQUEST_ID"; + + private Saml2RequestAttributeNames() { + + } + +} diff --git a/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java new file mode 100644 index 0000000000..c23d004432 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2Utils.java @@ -0,0 +1,79 @@ +/* + * 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.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterOutputStream; + +import org.apache.commons.codec.binary.Base64; + +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 static Base64 BASE64 = new Base64(0, new byte[] { '\n' }); + + private Saml2Utils() { + } + + static String samlEncode(byte[] b) { + return BASE64.encodeAsString(b); + } + + static byte[] samlDecode(String s) { + return BASE64.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/core/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java index d7a1fcdc34..7acb769f4a 100644 --- a/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/authentication/TestOpenSamlObjects.java +++ b/saml2/saml2-service-provider/core/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/core/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java index 2613e452b3..21e77cbb42 100644 --- a/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/metadata/OpenSamlMetadataResolverTests.java +++ b/saml2/saml2-service-provider/core/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/core/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java index 7d105aecf3..c5626821fc 100644 --- a/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java +++ b/saml2/saml2-service-provider/core/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/core/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java index 41418978c4..d8ebda2816 100644 --- a/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/DefaultRelyingPartyRegistrationResolverTests.java +++ b/saml2/saml2-service-provider/core/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/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java new file mode 100644 index 0000000000..ca567530b2 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/HttpSessionLogoutRequestRepositoryTests.java @@ -0,0 +1,242 @@ +/* + * 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.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 { + + private 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); + } + + // gh-5110 + @Test + public void loadLogoutRequestWhenMultipleSavedThenReturnMatchingLogoutRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + String state1 = "state-1122"; + Saml2LogoutRequest logoutRequest1 = createLogoutRequest().relayState(state1).build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest1, request, response); + String state2 = "state-3344"; + Saml2LogoutRequest logoutRequest2 = createLogoutRequest().relayState(state2).build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest2, request, response); + String state3 = "state-5566"; + Saml2LogoutRequest logoutRequest3 = createLogoutRequest().relayState(state3).build(); + this.logoutRequestRepository.saveLogoutRequest(logoutRequest3, request, response); + request.addParameter("RelayState", state1); + Saml2LogoutRequest loadedLogoutRequest1 = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(loadedLogoutRequest1).isEqualTo(logoutRequest1); + request.removeParameter("RelayState"); + request.addParameter("RelayState", state2); + Saml2LogoutRequest loadedLogoutRequest2 = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(loadedLogoutRequest2).isEqualTo(logoutRequest2); + request.removeParameter("RelayState"); + request.addParameter("RelayState", state3); + Saml2LogoutRequest loadedLogoutRequest3 = this.logoutRequestRepository.loadLogoutRequest(request); + assertThat(loadedLogoutRequest3).isEqualTo(logoutRequest3); + } + + @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/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandlerTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandlerTests.java new file mode 100644 index 0000000000..0e3e0b6665 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestHandlerTests.java @@ -0,0 +1,182 @@ +/* + * 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.ArrayList; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.saml.saml2.core.LogoutRequest; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.Saml2Exception; +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.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.OpenSamlSigningUtils.QueryParametersPartial; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +/** + * Tests for {@link OpenSamlLogoutRequestHandler} + * + * @author Josh Cummings + */ +public class OpenSamlLogoutRequestHandlerTests { + + private final RelyingPartyRegistrationResolver resolver = mock(RelyingPartyRegistrationResolver.class); + + private final OpenSamlLogoutRequestHandler handler = new OpenSamlLogoutRequestHandler(this.resolver); + + @Test + public void handleWhenAuthenticatedThenSavesRequestId() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + sign(logoutRequest, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutRequest); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + this.handler.logout(request, null, authentication); + String id = ((LogoutRequest) request.getAttribute(LogoutRequest.class.getName())).getID(); + assertThat(id).isEqualTo(logoutRequest.getID()); + } + + @Test + public void handleWhenRedirectBindingThenValidatesSignatureParameter() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = redirect(logoutRequest, OpenSamlSigningUtils.sign(registration)); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + this.handler.logout(request, null, authentication); + String id = ((LogoutRequest) request.getAttribute(LogoutRequest.class.getName())).getID(); + assertThat(id).isEqualTo(logoutRequest.getID()); + } + + @Test + public void handleWhenInvalidIssuerThenInvalidSignatureError() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.getIssuer().setValue("wrong"); + sign(logoutRequest, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutRequest); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + assertThatExceptionOfType(Saml2Exception.class) + .isThrownBy(() -> this.handler.logout(request, null, authentication)) + .withMessageContaining(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenMismatchedUserThenInvalidRequestError() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.getNameID().setValue("wrong"); + sign(logoutRequest, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutRequest); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + assertThatExceptionOfType(Saml2Exception.class) + .isThrownBy(() -> this.handler.logout(request, null, authentication)) + .withMessageContaining(Saml2ErrorCodes.INVALID_REQUEST); + } + + @Test + public void handleWhenMissingUserThenSubjectNotFoundError() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.setNameID(null); + sign(logoutRequest, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutRequest); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + assertThatExceptionOfType(Saml2Exception.class) + .isThrownBy(() -> this.handler.logout(request, null, authentication)) + .withMessageContaining(Saml2ErrorCodes.SUBJECT_NOT_FOUND); + } + + @Test + public void handleWhenMismatchedDestinationThenInvalidDestinationError() { + RelyingPartyRegistration registration = registration().build(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + logoutRequest.setDestination("wrong"); + sign(logoutRequest, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutRequest); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + assertThatExceptionOfType(Saml2Exception.class) + .isThrownBy(() -> this.handler.logout(request, null, authentication)) + .withMessageContaining(Saml2ErrorCodes.INVALID_DESTINATION); + } + + private RelyingPartyRegistration.Builder registration() { + return signing(verifying(TestRelyingPartyRegistrations.noCredentials())); + } + + 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) { + return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response", + new ArrayList<>(), registration.getRegistrationId()); + } + + private MockHttpServletRequest post(LogoutRequest logoutRequest) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setParameter("SAMLRequest", + Saml2Utils.samlEncode(serialize(logoutRequest).getBytes(StandardCharsets.UTF_8))); + return request; + } + + private MockHttpServletRequest redirect(LogoutRequest logoutRequest, QueryParametersPartial partial) { + String serialized = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(serialize(logoutRequest))); + Map parameters = partial.param("SAMLRequest", serialized).parameters(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameters(parameters); + request.setMethod("GET"); + return request; + } + + 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/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java new file mode 100644 index 0000000000..cb0b68ae5c --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutRequestResolverTests.java @@ -0,0 +1,112 @@ +/* + * 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.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.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +/** + * Tests for {@link OpenSamlLogoutRequestResolver} + * + * @author Josh Cummings + */ +public class OpenSamlLogoutRequestResolverTests { + + private final RelyingPartyRegistrationResolver resolver = mock(RelyingPartyRegistrationResolver.class); + + private final OpenSamlLogoutRequestResolver logoutResolver = new OpenSamlLogoutRequestResolver(this.resolver); + + @Test + public void resolveRedirectWhenAuthenticatedThenIncludesName() { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + Saml2Authentication authentication = authentication(registration); + HttpServletRequest request = new MockHttpServletRequest(); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + Saml2LogoutRequest saml2LogoutRequest = this.logoutResolver.resolveLogoutRequest(request, authentication) + .logoutRequest(); + assertThat(saml2LogoutRequest.getParameter("SigAlg")).isNotNull(); + assertThat(saml2LogoutRequest.getParameter("Signature")).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.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + Saml2LogoutRequest saml2LogoutRequest = this.logoutResolver.resolveLogoutRequest(request, authentication) + .logoutRequest(); + assertThat(saml2LogoutRequest.getParameter("SigAlg")).isNull(); + assertThat(saml2LogoutRequest.getParameter("Signature")).isNull(); + Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(); + LogoutRequest logoutRequest = getLogoutRequest(saml2LogoutRequest.getSamlRequest(), binding); + assertThat(logoutRequest.getNameID().getValue()).isEqualTo(authentication.getName()); + } + + private Saml2Authentication authentication(RelyingPartyRegistration registration) { + return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response", + new ArrayList<>(), registration.getRegistrationId()); + } + + 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/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandlerTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandlerTests.java new file mode 100644 index 0000000000..3030f68b88 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseHandlerTests.java @@ -0,0 +1,189 @@ +/* + * 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.ArrayList; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.saml.saml2.core.LogoutResponse; +import org.opensaml.saml.saml2.core.StatusCode; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.Saml2Exception; +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.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 org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlSigningUtils.QueryParametersPartial; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +/** + * Tests for {@link OpenSamlLogoutResponseHandler} + * + * @author Josh Cummings + */ +public class OpenSamlLogoutResponseHandlerTests { + + private final RelyingPartyRegistrationResolver resolver = mock(RelyingPartyRegistrationResolver.class); + + private final Saml2LogoutRequestRepository repository = mock(Saml2LogoutRequestRepository.class); + + private final OpenSamlLogoutResponseHandler handler = new OpenSamlLogoutResponseHandler(this.resolver); + + @Before + public void setUp() { + this.handler.setLogoutRequestRepository(this.repository); + } + + @Test + public void handleWhenAuthenticatedThenHandles() { + RelyingPartyRegistration registration = signing(verifying(registration())).build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + sign(logoutResponse, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutResponse); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + this.handler.logout(request, null, authentication); + } + + @Test + public void handleWhenRedirectBindingThenValidatesSignatureParameter() { + RelyingPartyRegistration registration = signing(verifying(registration())).build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = redirect(logoutResponse, OpenSamlSigningUtils.sign(registration)); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + this.handler.logout(request, null, authentication); + } + + @Test + public void handleWhenInvalidIssuerThenInvalidSignatureError() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.getIssuer().setValue("wrong"); + sign(logoutResponse, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutResponse); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + assertThatExceptionOfType(Saml2Exception.class) + .isThrownBy(() -> this.handler.logout(request, null, authentication)) + .withMessageContaining(Saml2ErrorCodes.INVALID_SIGNATURE); + } + + @Test + public void handleWhenMismatchedDestinationThenInvalidDestinationError() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.setDestination("wrong"); + sign(logoutResponse, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutResponse); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + assertThatExceptionOfType(Saml2Exception.class) + .isThrownBy(() -> this.handler.logout(request, null, authentication)) + .withMessageContaining(Saml2ErrorCodes.INVALID_DESTINATION); + } + + @Test + public void handleWhenStatusNotSuccessThenInvalidResponseError() { + RelyingPartyRegistration registration = registration().build(); + Saml2LogoutRequest logoutRequest = Saml2LogoutRequest.withRelyingPartyRegistration(registration).id("id") + .build(); + given(this.repository.removeLogoutRequest(any(), any())).willReturn(logoutRequest); + LogoutResponse logoutResponse = TestOpenSamlObjects.assertingPartyLogoutResponse(registration); + logoutResponse.getStatus().getStatusCode().setValue(StatusCode.UNKNOWN_PRINCIPAL); + sign(logoutResponse, registration); + Authentication authentication = authentication(registration); + MockHttpServletRequest request = post(logoutResponse); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + assertThatExceptionOfType(Saml2Exception.class) + .isThrownBy(() -> this.handler.logout(request, null, authentication)) + .withMessageContaining(Saml2ErrorCodes.INVALID_RESPONSE); + } + + private RelyingPartyRegistration.Builder registration() { + return signing(verifying(TestRelyingPartyRegistrations.noCredentials())); + } + + 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) { + return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response", + new ArrayList<>(), registration.getRegistrationId()); + } + + private MockHttpServletRequest post(LogoutResponse logoutResponse) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setParameter("SAMLResponse", + Saml2Utils.samlEncode(serialize(logoutResponse).getBytes(StandardCharsets.UTF_8))); + return request; + } + + private MockHttpServletRequest redirect(LogoutResponse logoutResponse, QueryParametersPartial partial) { + String serialized = Saml2Utils.samlEncode(Saml2Utils.samlDeflate(serialize(logoutResponse))); + Map parameters = partial.param("SAMLResponse", serialized).parameters(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameters(parameters); + request.setMethod("GET"); + return request; + } + + 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/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.java new file mode 100644 index 0000000000..4ee2b32f4e --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSamlLogoutResponseResolverTests.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.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.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.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.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +/** + * Tests for {@link OpenSamlLogoutResponseResolver} + * + * @author Josh Cummings + */ +public class OpenSamlLogoutResponseResolverTests { + + private final RelyingPartyRegistrationResolver resolver = mock(RelyingPartyRegistrationResolver.class); + + private final OpenSamlLogoutResponseResolver logoutResolver = new OpenSamlLogoutResponseResolver(this.resolver); + + @Test + public void resolveRedirectWhenAuthenticatedThenSuccess() { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + Saml2Authentication authentication = authentication(registration); + HttpServletRequest request = new MockHttpServletRequest(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + request.setAttribute(LogoutRequest.class.getName(), logoutRequest); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + Saml2LogoutResponse saml2LogoutResponse = this.logoutResolver.resolveLogoutResponse(request, authentication) + .logoutResponse(); + assertThat(saml2LogoutResponse.getParameter("SigAlg")).isNotNull(); + assertThat(saml2LogoutResponse.getParameter("Signature")).isNotNull(); + 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(); + Saml2Authentication authentication = authentication(registration); + HttpServletRequest request = new MockHttpServletRequest(); + LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration); + request.setAttribute(LogoutRequest.class.getName(), logoutRequest); + given(this.resolver.resolve(request, registration.getRegistrationId())).willReturn(registration); + Saml2LogoutResponse saml2LogoutResponse = this.logoutResolver.resolveLogoutResponse(request, authentication) + .logoutResponse(); + assertThat(saml2LogoutResponse.getParameter("SigAlg")).isNull(); + assertThat(saml2LogoutResponse.getParameter("Signature")).isNull(); + Saml2MessageBinding binding = registration.getAssertingPartyDetails().getSingleLogoutServiceBinding(); + LogoutResponse logoutResponse = getLogoutResponse(saml2LogoutResponse.getSamlResponse(), binding); + assertThat(logoutResponse.getStatus().getStatusCode().getValue()).isEqualTo(StatusCode.SUCCESS); + } + + private Saml2Authentication authentication(RelyingPartyRegistration registration) { + return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response", + new ArrayList<>(), registration.getRegistrationId()); + } + + 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/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java new file mode 100644 index 0000000000..c65fcae670 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilterTests.java @@ -0,0 +1,116 @@ +/* + * 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.After; +import org.junit.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.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verifyNoInteractions; + +public class Saml2LogoutRequestFilterTests { + + private final LogoutHandler handler = mock(LogoutHandler.class); + + private final LogoutSuccessHandler successHandler = mock(LogoutSuccessHandler.class); + + private final Saml2LogoutRequestFilter filter = new Saml2LogoutRequestFilter(this.successHandler, this.handler); + + @After + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void doFilterWhenSamlRequestMatchesThenLogout() 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("SAMLRequest", "request"); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.filter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.handler).logout(request, response, authentication); + verify(this.successHandler).onLogoutSuccess(request, response, authentication); + } + + @Test + public void doFilterWhenSamlResponseMatchesThenLogout() 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("SAMLRequest", "request"); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.filter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.handler).logout(request, response, authentication); + verify(this.successHandler).onLogoutSuccess(request, response, authentication); + } + + @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.filter.doFilterInternal(request, response, new MockFilterChain()); + verifyNoInteractions(this.handler); + verifyNoInteractions(this.successHandler); + } + + @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.filter.doFilterInternal(request, response, new MockFilterChain()); + verifyNoInteractions(this.handler); + verifyNoInteractions(this.successHandler); + } + + @Test + public void doFilterWhenLogoutHandlerFailsThenNoSuccessHandler() { + 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(); + willThrow(RuntimeException.class).given(this.handler).logout(request, response, authentication); + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> this.filter.doFilterInternal(request, response, new MockFilterChain())); + verify(this.handler).logout(request, response, authentication); + verifyNoInteractions(this.successHandler); + } + +} diff --git a/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandlerTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandlerTests.java new file mode 100644 index 0000000000..eb3459e279 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestSuccessHandlerTests.java @@ -0,0 +1,111 @@ +/* + * 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.After; +import org.junit.Before; +import org.junit.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 org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestResolver.Saml2LogoutRequestBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.RETURNS_SELF; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.willReturn; + +/** + * Tests for {@link Saml2LogoutRequestSuccessHandler} + * + * @author Josh Cummings + */ +public class Saml2LogoutRequestSuccessHandlerTests { + + private final Saml2LogoutRequestResolver resolver = mock(Saml2LogoutRequestResolver.class); + + private final Saml2LogoutRequestRepository repository = mock(Saml2LogoutRequestRepository.class); + + private final Saml2LogoutRequestSuccessHandler handler = new Saml2LogoutRequestSuccessHandler(this.resolver); + + @Before + public void setUp() { + this.handler.setLogoutRequestRepository(this.repository); + } + + @After + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void doFilterWhenRedirectThenRedirectsToAssertingParty() 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(); + Saml2LogoutRequestBuilder partial = mock(Saml2LogoutRequestBuilder.class, RETURNS_SELF); + given(partial.logoutRequest()).willReturn(logoutRequest); + willReturn(partial).given(this.resolver).resolveLogoutRequest(request, authentication); + this.handler.onLogoutSuccess(request, response, authentication); + String content = response.getHeader("Location"); + assertThat(content).contains("SAMLRequest"); + assertThat(content).startsWith(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation()); + } + + @Test + public void doFilterWhenPostThenPostsToAssertingParty() 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(); + Saml2LogoutRequestBuilder partial = mock(Saml2LogoutRequestBuilder.class, RETURNS_SELF); + given(partial.logoutRequest()).willReturn(logoutRequest); + willReturn(partial).given(this.resolver).resolveLogoutRequest(request, authentication); + this.handler.onLogoutSuccess(request, response, authentication); + String content = response.getContentAsString(); + assertThat(content).contains("SAMLRequest"); + assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation()); + } + + private Saml2Authentication authentication(RelyingPartyRegistration registration) { + return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response", + new ArrayList<>(), registration.getRegistrationId()); + } + +} diff --git a/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java new file mode 100644 index 0000000000..2a98bc1905 --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseFilterTests.java @@ -0,0 +1,122 @@ +/* + * 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.After; +import org.junit.Before; +import org.junit.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.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verifyNoInteractions; + +public class Saml2LogoutResponseFilterTests { + + private final LogoutHandler handler = mock(LogoutHandler.class); + + private final LogoutSuccessHandler successHandler = mock(LogoutSuccessHandler.class); + + private final Saml2LogoutResponseFilter filter = new Saml2LogoutResponseFilter(this.handler); + + @Before + public void setUp() { + this.filter.setLogoutSuccessHandler(this.successHandler); + } + + @After + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void doFilterWhenSamlRequestMatchesThenLogout() 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(); + this.filter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.handler).logout(request, response, authentication); + verify(this.successHandler).onLogoutSuccess(request, response, authentication); + } + + @Test + public void doFilterWhenSamlResponseMatchesThenLogout() 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(); + this.filter.doFilterInternal(request, response, new MockFilterChain()); + verify(this.handler).logout(request, response, authentication); + verify(this.successHandler).onLogoutSuccess(request, response, authentication); + } + + @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.filter.doFilterInternal(request, response, new MockFilterChain()); + verifyNoInteractions(this.handler); + verifyNoInteractions(this.successHandler); + } + + @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"); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.filter.doFilterInternal(request, response, new MockFilterChain()); + verifyNoInteractions(this.handler); + verifyNoInteractions(this.successHandler); + } + + @Test + public void doFilterWhenLogoutHandlerFailsThenNoSuccessHandler() { + 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(); + willThrow(RuntimeException.class).given(this.handler).logout(request, response, authentication); + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> this.filter.doFilterInternal(request, response, new MockFilterChain())); + verify(this.handler).logout(request, response, authentication); + verifyNoInteractions(this.successHandler); + } + +} diff --git a/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandlerTests.java b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandlerTests.java new file mode 100644 index 0000000000..7cb61800ad --- /dev/null +++ b/saml2/saml2-service-provider/core/src/test/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutResponseSuccessHandlerTests.java @@ -0,0 +1,95 @@ +/* + * 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.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +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.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.authentication.logout.Saml2LogoutResponseResolver.Saml2LogoutResponseBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.RETURNS_SELF; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.willReturn; + +/** + * Tests for {@link Saml2LogoutResponseSuccessHandler} + * + * @author Josh Cummings + */ +public class Saml2LogoutResponseSuccessHandlerTests { + + private final Saml2LogoutResponseResolver resolver = mock(Saml2LogoutResponseResolver.class); + + private final Saml2LogoutResponseSuccessHandler handler = new Saml2LogoutResponseSuccessHandler(this.resolver); + + @Test + public void doFilterWhenRedirectThenRedirectsToAssertingParty() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full().build(); + Authentication authentication = authentication(registration); + Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration) + .samlResponse("response").build(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(Saml2RequestAttributeNames.LOGOUT_REQUEST_ID, "id"); + MockHttpServletResponse response = new MockHttpServletResponse(); + Saml2LogoutResponseBuilder partial = mock(Saml2LogoutResponseBuilder.class, RETURNS_SELF); + given(partial.logoutResponse()).willReturn(logoutResponse); + willReturn(partial).given(this.resolver).resolveLogoutResponse(request, authentication); + this.handler.onLogoutSuccess(request, response, authentication); + String content = response.getHeader("Location"); + assertThat(content).contains("SAMLResponse"); + assertThat(content) + .startsWith(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation()); + } + + @Test + public void doFilterWhenPostThenPostsToAssertingParty() throws Exception { + RelyingPartyRegistration registration = TestRelyingPartyRegistrations.full() + .assertingPartyDetails((party) -> party.singleLogoutServiceBinding(Saml2MessageBinding.POST)).build(); + Authentication authentication = authentication(registration); + Saml2LogoutResponse logoutResponse = Saml2LogoutResponse.withRelyingPartyRegistration(registration) + .samlResponse("response").build(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(Saml2RequestAttributeNames.LOGOUT_REQUEST_ID, "id"); + MockHttpServletResponse response = new MockHttpServletResponse(); + Saml2LogoutResponseBuilder partial = mock(Saml2LogoutResponseBuilder.class, RETURNS_SELF); + given(partial.logoutResponse()).willReturn(logoutResponse); + willReturn(partial).given(this.resolver).resolveLogoutResponse(request, authentication); + this.handler.onLogoutSuccess(request, response, authentication); + String content = response.getContentAsString(); + assertThat(content).contains("SAMLResponse"); + assertThat(content).contains(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation()); + } + + private Saml2Authentication authentication(RelyingPartyRegistration registration) { + return new Saml2Authentication(new DefaultSaml2AuthenticatedPrincipal("user", new HashMap<>()), "response", + new ArrayList<>(), registration.getRegistrationId()); + } + +} diff --git a/saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java b/saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java new file mode 100644 index 0000000000..a5bd7c49b7 --- /dev/null +++ b/saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutRequestResolver.java @@ -0,0 +1,91 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; + +import org.joda.time.DateTime; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlLogoutRequestResolver.OpenSamlLogoutRequestBuilder; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with + * OpenSAML 3 + * + * @author Josh Cummings + * @since 5.5 + * @deprecated Because OpenSAML 3 has reached End-of-Life, please update to + * {@code OpenSaml4LogoutRequestResolver} + */ +public class OpenSaml3LogoutRequestResolver implements Saml2LogoutRequestResolver { + + private final OpenSamlLogoutRequestResolver logoutRequestResolver; + + private Clock clock = Clock.systemUTC(); + + /** + * Construct a {@link OpenSaml3LogoutRequestResolver} with the provided parameters + * @param relyingPartyRegistrationResolver a strategy for resolving a + * {@link RelyingPartyRegistration} + */ + public OpenSaml3LogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.logoutRequestResolver = new OpenSamlLogoutRequestResolver(relyingPartyRegistrationResolver); + } + + /** + * 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}. The + * request also contains its issued {@link DateTime}. + * + * The {@link Authentication} must be of type {@link Saml2Authentication} in order to + * look up the {@link RelyingPartyRegistration} that holds the signing key. + * @param request the HTTP request + * @param authentication the current principal details + * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request + * that the resolver supplied + */ + @Override + public Saml2LogoutRequestBuilder resolveLogoutRequest(HttpServletRequest request, + Authentication authentication) { + OpenSamlLogoutRequestBuilder builder = this.logoutRequestResolver.resolveLogoutRequest(request, authentication); + if (builder == null) { + return null; + } + return builder + .logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(new DateTime(this.clock.millis()))); + } + + /** + * 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; + } + +} diff --git a/saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java b/saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java new file mode 100644 index 0000000000..8d5c2138f1 --- /dev/null +++ b/saml2/saml2-service-provider/opensaml3/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml3LogoutResponseResolver.java @@ -0,0 +1,89 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; + +import org.joda.time.DateTime; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlLogoutResponseResolver.OpenSamlLogoutResponseBuilder; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutResponseResolver} for resolving SAML 2.0 Logout Responses with + * OpenSAML 3 + * + * @author Josh Cummings + * @since 5.5 + * @deprecated Because OpenSAML 3 has reached End-of-Life, please update to + * {@code OpenSaml4LogoutResponseResolver} + */ +public class OpenSaml3LogoutResponseResolver implements Saml2LogoutResponseResolver { + + private final OpenSamlLogoutResponseResolver logoutResponseResolver; + + private Clock clock = Clock.systemUTC(); + + /** + * Construct a {@link OpenSaml3LogoutResponseResolver} with the provided parameters + * @param relyingPartyRegistrationResolver a strategy for resolving a + * {@link RelyingPartyRegistration} + */ + public OpenSaml3LogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.logoutResponseResolver = new OpenSamlLogoutResponseResolver(relyingPartyRegistrationResolver); + } + + /** + * 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 also includes an issued {@link DateTime} and is marked as + * {@code SUCCESS}. + * + * The {@link Authentication} must be of type {@link Saml2Authentication} in order to + * look up the {@link RelyingPartyRegistration} that holds the signing key. + * @param request the HTTP request + * @param authentication the current principal details + * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request + * that the resolver supplied + */ + @Override + public Saml2LogoutResponseBuilder resolveLogoutResponse(HttpServletRequest request, + Authentication authentication) { + OpenSamlLogoutResponseBuilder builder = this.logoutResponseResolver.resolveLogoutResponse(request, + authentication); + if (builder == null) { + return null; + } + return builder + .logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(new DateTime(this.clock.millis()))); + } + + public void setClock(Clock clock) { + Assert.notNull(clock, "clock must not be null"); + this.clock = clock; + } + +} diff --git a/saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java b/saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java new file mode 100644 index 0000000000..07397f15d2 --- /dev/null +++ b/saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutRequestResolver.java @@ -0,0 +1,87 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlLogoutRequestResolver.OpenSamlLogoutRequestBuilder; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutRequestResolver} for resolving SAML 2.0 Logout Requests with + * OpenSAML 4 + * + * @author Josh Cummings + * @since 5.5 + */ +public class OpenSaml4LogoutRequestResolver implements Saml2LogoutRequestResolver { + + private final OpenSamlLogoutRequestResolver logoutRequestResolver; + + private Clock clock = Clock.systemUTC(); + + /** + * Construct a {@link OpenSaml4LogoutRequestResolver} with the provided parameters + * @param relyingPartyRegistrationResolver a strategy for resolving a + * {@link RelyingPartyRegistration} + */ + public OpenSaml4LogoutRequestResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.logoutRequestResolver = new OpenSamlLogoutRequestResolver(relyingPartyRegistrationResolver); + } + + /** + * 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}. The + * request also contains its issued {@link Instant}. + * + * The {@link Authentication} must be of type {@link Saml2Authentication} in order to + * look up the {@link RelyingPartyRegistration} that holds the signing key. + * @param request the HTTP request + * @param authentication the current principal details + * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request + * that the resolver supplied + */ + @Override + public Saml2LogoutRequestBuilder resolveLogoutRequest(HttpServletRequest request, + Authentication authentication) { + OpenSamlLogoutRequestBuilder builder = this.logoutRequestResolver.resolveLogoutRequest(request, authentication); + if (builder == null) { + return null; + } + return builder.logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(Instant.now(this.clock))); + } + + /** + * 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; + } + +} diff --git a/saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java b/saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java new file mode 100644 index 0000000000..5d2c9c0a99 --- /dev/null +++ b/saml2/saml2-service-provider/opensaml4/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/OpenSaml4LogoutResponseResolver.java @@ -0,0 +1,85 @@ +/* + * 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 javax.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSamlLogoutResponseResolver.OpenSamlLogoutResponseBuilder; +import org.springframework.util.Assert; + +/** + * A {@link Saml2LogoutResponseResolver} for resolving SAML 2.0 Logout Responses with + * OpenSAML 4 + * + * @author Josh Cummings + * @since 5.5 + */ +public class OpenSaml4LogoutResponseResolver implements Saml2LogoutResponseResolver { + + private final OpenSamlLogoutResponseResolver logoutResponseResolver; + + private Clock clock = Clock.systemUTC(); + + /** + * Construct a {@link OpenSaml4LogoutResponseResolver} with the provided parameters + * @param relyingPartyRegistrationResolver the strategy for resolving a + * {@link RelyingPartyRegistration} + */ + public OpenSaml4LogoutResponseResolver(RelyingPartyRegistrationResolver relyingPartyRegistrationResolver) { + this.logoutResponseResolver = new OpenSamlLogoutResponseResolver(relyingPartyRegistrationResolver); + } + + /** + * 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 also includes an issued {@link Instant} and is marked as + * {@code SUCCESS}. + * + * The {@link Authentication} must be of type {@link Saml2Authentication} in order to + * look up the {@link RelyingPartyRegistration} that holds the signing key. + * @param request the HTTP request + * @param authentication the current principal details + * @return a builder, useful for overriding any aspects of the SAML 2.0 Logout Request + * that the resolver supplied + */ + @Override + public Saml2LogoutResponseBuilder resolveLogoutResponse(HttpServletRequest request, + Authentication authentication) { + OpenSamlLogoutResponseBuilder builder = this.logoutResponseResolver.resolveLogoutResponse(request, + authentication); + if (builder == null) { + return null; + } + return builder.logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(Instant.now(this.clock))); + } + + public void setClock(Clock clock) { + Assert.notNull(clock, "clock must not be null"); + this.clock = clock; + } + +}