From 8a355240bc78af06c03703f60446f2d3acbc1e5f Mon Sep 17 00:00:00 2001 From: Jakub Kubrynski Date: Tue, 30 Jun 2020 23:06:32 +0200 Subject: [PATCH] SAML 2.0 SP Metadata Endpoint Support Issue gh-8693 --- .../web/builders/FilterComparator.java | 3 + .../saml2/Saml2LoginConfigurer.java | 27 +++ .../config/web/servlet/Saml2DslTests.kt | 2 +- .../_includes/servlet/saml2/saml2-login.adoc | 18 +- .../RelyingPartyRegistration.java | 28 +++ .../service/web/OpenSamlMetadataResolver.java | 161 ++++++++++++++++++ .../service/web/Saml2MetadataFilter.java | 81 +++++++++ .../service/web/Saml2MetadataResolver.java | 29 ++++ .../RelyingPartyRegistrationTests.java | 2 +- .../TestRelyingPartyRegistrations.java | 2 +- .../web/OpenSamlMetadataResolverTest.java | 67 ++++++++ .../service/web/Saml2MetadataFilterTest.java | 114 +++++++++++++ .../samples/config/SecurityConfig.java | 2 +- .../samples/config/SecurityConfigTests.java | 2 +- 14 files changed, 518 insertions(+), 20 deletions(-) create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/OpenSamlMetadataResolver.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilter.java create mode 100644 saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataResolver.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/OpenSamlMetadataResolverTest.java create mode 100644 saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilterTest.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java index 4b96267a59..0fe28ab3ed 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java @@ -73,6 +73,9 @@ final class FilterComparator implements Comparator, Serializable { filterToOrder.put( "org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter", order.next()); + filterToOrder.put( + "org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter", + order.next()); filterToOrder.put( "org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter", order.next()); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java index ca7b6d2c08..c9ed75e35e 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -38,8 +38,11 @@ import org.springframework.security.saml2.provider.service.servlet.filter.Saml2W import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter; import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.DefaultSaml2AuthenticationRequestContextResolver; +import org.springframework.security.saml2.provider.service.web.OpenSamlMetadataResolver; import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestContextResolver; import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationTokenConverter; +import org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter; +import org.springframework.security.saml2.provider.service.web.Saml2MetadataResolver; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; @@ -110,10 +113,15 @@ public final class Saml2LoginConfigurer> extend private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; private AuthenticationConverter authenticationConverter; + + private Saml2MetadataResolver saml2MetadataResolver; + private AuthenticationManager authenticationManager; private Saml2WebSsoAuthenticationFilter saml2WebSsoAuthenticationFilter; + private Saml2MetadataFilter saml2MetadataFilter; + /** * Use this {@link AuthenticationConverter} when converting incoming requests to an {@link Authentication}. * By default the {@link Saml2AuthenticationTokenConverter} is used. @@ -154,6 +162,16 @@ public final class Saml2LoginConfigurer> extend return this; } + /** + * Sets the {@code Saml2MetadataResolver} + * @param saml2MetadataResolver the implementation of the metadata resolver + * @return the {@link Saml2LoginConfigurer} for further configuration + */ + public Saml2LoginConfigurer saml2MetadataResolver(Saml2MetadataResolver saml2MetadataResolver) { + this.saml2MetadataResolver = saml2MetadataResolver; + return this; + } + /** * {@inheritDoc} */ @@ -211,6 +229,14 @@ public final class Saml2LoginConfigurer> extend setAuthenticationFilter(saml2WebSsoAuthenticationFilter); super.loginProcessingUrl(this.loginProcessingUrl); + if (this.saml2MetadataResolver == null) { + this.saml2MetadataResolver = new OpenSamlMetadataResolver(); + } + + saml2MetadataFilter = new Saml2MetadataFilter( + this.relyingPartyRegistrationRepository, this.saml2MetadataResolver + ); + if (hasText(this.loginPage)) { // Set custom login page super.loginPage(this.loginPage); @@ -250,6 +276,7 @@ public final class Saml2LoginConfigurer> extend @Override public void configure(B http) throws Exception { http.addFilter(this.authenticationRequestEndpoint.build(http)); + http.addFilter(saml2MetadataFilter); super.configure(http); if (this.authenticationManager == null) { registerDefaultAuthenticationProvider(http); diff --git a/config/src/test/kotlin/org/springframework/security/config/web/servlet/Saml2DslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/servlet/Saml2DslTests.kt index bf6dd2f85e..1d8e99fda8 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/servlet/Saml2DslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/servlet/Saml2DslTests.kt @@ -30,7 +30,7 @@ import org.springframework.security.saml2.credentials.Saml2X509Credential import org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.VERIFICATION import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration -import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter +import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationFilter import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import java.security.cert.Certificate 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 6131ccc969..ecd18b80ef 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 @@ -61,8 +61,7 @@ the IDP sends an assertion to the SP. 1. Mappings assertion conditions and attributes to session features (timeout, tracking, etc) 2. Single logout -3. Dynamic metadata generation -4. Receiving and validating standalone assertion (not wrapped in a response object) +3. Receiving and validating standalone assertion (not wrapped in a response object) [[servlet-saml2-javaconfig]] === Saml 2 Login - Introduction to Java Configuration @@ -200,19 +199,8 @@ credentials on all the identity providers. [[servlet-saml2-serviceprovider-metadata]] ==== Service Provider Metadata -The Spring Security SAML 2 implementation does not yet provide an endpoint for downloading -SP metadata in XML format. The minimal pieces that are exchanged - -* *entity ID* - defaults to `+{baseUrl}/saml2/service-provider-metadata/{registrationId}+` -Other known configuration names that also use this same value -** Audience Restriction -* *single signon URL* - defaults to `+{baseUrl}/login/saml2/sso/{registrationId}+` -Other known configuration names that also use this same value -** Recipient URL -** Destination URL -** Assertion Consumer Service URL -* X509Certificate - the certificate that you configure as part of your {SIGNING,DECRYPTION} -credentials must be shared with the Identity Provider +The Spring Security SAML 2 implementation does provide an endpoint for downloading +SP metadata in XML format. The provider is mapped to: `+{baseUrl}/saml2/service-provider-metadata/{registrationId}+` [[servlet-saml2-sp-initiated]] ==== Authentication Requests - SP Initiated Flow diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java index 9d6d0e2662..c20eebaf06 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistration.java @@ -29,6 +29,7 @@ import java.util.function.Function; import org.springframework.security.saml2.core.Saml2X509Credential; import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; +import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationFilter; import org.springframework.util.Assert; /** @@ -360,6 +361,7 @@ public class RelyingPartyRegistration { .encryptionX509Credentials(c -> c.addAll(registration.getAssertingPartyDetails().getEncryptionX509Credentials())) .singleSignOnServiceLocation(registration.getAssertingPartyDetails().getSingleSignOnServiceLocation()) .singleSignOnServiceBinding(registration.getAssertingPartyDetails().getSingleSignOnServiceBinding()) + .nameIdFormat(registration.getAssertingPartyDetails().getNameIdFormat()) ); } @@ -375,6 +377,7 @@ public class RelyingPartyRegistration { private final Collection verificationX509Credentials; private final Collection encryptionX509Credentials; private final String singleSignOnServiceLocation; + private final String nameIdFormat; private final Saml2MessageBinding singleSignOnServiceBinding; private AssertingPartyDetails( @@ -383,6 +386,7 @@ public class RelyingPartyRegistration { Collection verificationX509Credentials, Collection encryptionX509Credentials, String singleSignOnServiceLocation, + String nameIdFormat, Saml2MessageBinding singleSignOnServiceBinding) { Assert.hasText(entityId, "entityId cannot be null or empty"); @@ -405,6 +409,7 @@ public class RelyingPartyRegistration { this.verificationX509Credentials = verificationX509Credentials; this.encryptionX509Credentials = encryptionX509Credentials; this.singleSignOnServiceLocation = singleSignOnServiceLocation; + this.nameIdFormat = nameIdFormat; this.singleSignOnServiceBinding = singleSignOnServiceBinding; } @@ -472,6 +477,15 @@ public class RelyingPartyRegistration { return this.singleSignOnServiceLocation; } + /** + * Get the NameIDFormat setting, indicating which user property should be used as a NameID Format attribute + * + * @return the NameIdFormat value + */ + public String getNameIdFormat() { + return nameIdFormat; + } + /** * Get the * SingleSignOnService @@ -493,6 +507,7 @@ public class RelyingPartyRegistration { private Collection verificationX509Credentials = new HashSet<>(); private Collection encryptionX509Credentials = new HashSet<>(); private String singleSignOnServiceLocation; + private String nameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"; private Saml2MessageBinding singleSignOnServiceBinding = Saml2MessageBinding.REDIRECT; /** @@ -562,6 +577,18 @@ public class RelyingPartyRegistration { return this; } + /** + * Set the preference for name identifier returned by IdP. + * See for possible values + * + * @param nameIdFormat the name identifier + * @return the {@link ProviderDetails.Builder} for further configuration + */ + public Builder nameIdFormat(String nameIdFormat) { + this.nameIdFormat = nameIdFormat; + return this; + } + /** * Set the * SingleSignOnService @@ -590,6 +617,7 @@ public class RelyingPartyRegistration { this.verificationX509Credentials, this.encryptionX509Credentials, this.singleSignOnServiceLocation, + this.nameIdFormat, this.singleSignOnServiceBinding ); } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/OpenSamlMetadataResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/OpenSamlMetadataResolver.java new file mode 100644 index 0000000000..6b773060d1 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/OpenSamlMetadataResolver.java @@ -0,0 +1,161 @@ +/* + * 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.web; + +import net.shibboleth.utilities.java.support.xml.SerializeSupport; +import org.opensaml.core.xml.XMLObjectBuilder; +import org.opensaml.core.xml.XMLObjectBuilderFactory; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.core.xml.io.Marshaller; +import org.opensaml.saml.common.xml.SAMLConstants; +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.NameIDFormat; +import org.opensaml.saml.saml2.metadata.SPSSODescriptor; +import org.opensaml.security.credential.UsageType; +import org.opensaml.xmlsec.signature.KeyInfo; +import org.opensaml.xmlsec.signature.X509Certificate; +import org.opensaml.xmlsec.signature.X509Data; +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.credentials.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.servlet.filter.Saml2ServletUtils; +import org.w3c.dom.Element; + +import javax.servlet.http.HttpServletRequest; +import javax.xml.namespace.QName; +import java.security.cert.CertificateEncodingException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * @author Jakub Kubrynski + * @since 5.4 + */ +public class OpenSamlMetadataResolver implements Saml2MetadataResolver { + + @Override + public String resolveMetadata(HttpServletRequest request, RelyingPartyRegistration registration) { + + XMLObjectBuilderFactory builderFactory = XMLObjectProviderRegistrySupport.getBuilderFactory(); + + EntityDescriptor entityDescriptor = buildObject(builderFactory, EntityDescriptor.ELEMENT_QNAME); + + entityDescriptor.setEntityID( + resolveTemplate(registration.getEntityId(), registration, request)); + + SPSSODescriptor spSsoDescriptor = buildSpSsoDescriptor(registration, builderFactory, request); + entityDescriptor.getRoleDescriptors(SPSSODescriptor.DEFAULT_ELEMENT_NAME).add(spSsoDescriptor); + + return serializeToXmlString(entityDescriptor); + } + + private String serializeToXmlString(EntityDescriptor entityDescriptor) { + Marshaller marshaller = XMLObjectProviderRegistrySupport.getMarshallerFactory().getMarshaller(entityDescriptor); + if (marshaller == null) { + throw new Saml2Exception("Unable to resolve Marshaller"); + } + Element element; + try { + element = marshaller.marshall(entityDescriptor); + } catch (Exception e) { + throw new Saml2Exception(e); + } + return SerializeSupport.prettyPrintXML(element); + } + + private SPSSODescriptor buildSpSsoDescriptor(RelyingPartyRegistration registration, + XMLObjectBuilderFactory builderFactory, HttpServletRequest request) { + + SPSSODescriptor spSsoDescriptor = buildObject(builderFactory, SPSSODescriptor.DEFAULT_ELEMENT_NAME); + spSsoDescriptor.setAuthnRequestsSigned(registration.getAssertingPartyDetails().getWantAuthnRequestsSigned()); + spSsoDescriptor.setWantAssertionsSigned(true); + spSsoDescriptor.addSupportedProtocol(SAMLConstants.SAML20P_NS); + + NameIDFormat nameIdFormat = buildObject(builderFactory, NameIDFormat.DEFAULT_ELEMENT_NAME); + nameIdFormat.setFormat(registration.getAssertingPartyDetails().getNameIdFormat()); + spSsoDescriptor.getNameIDFormats().add(nameIdFormat); + + spSsoDescriptor.getAssertionConsumerServices().add( + buildAssertionConsumerService(registration, builderFactory, request)); + + spSsoDescriptor.getKeyDescriptors().addAll(buildKeys(builderFactory, + registration.getSigningCredentials(), UsageType.SIGNING)); + spSsoDescriptor.getKeyDescriptors().addAll(buildKeys(builderFactory, + registration.getEncryptionCredentials(), UsageType.ENCRYPTION)); + + return spSsoDescriptor; + } + + private List buildKeys(XMLObjectBuilderFactory builderFactory, + List credentials, UsageType usageType) { + List list = new ArrayList<>(); + for (Saml2X509Credential credential : credentials) { + KeyDescriptor keyDescriptor = buildKeyDescriptor(builderFactory, usageType, credential.getCertificate()); + list.add(keyDescriptor); + } + return list; + } + + private KeyDescriptor buildKeyDescriptor(XMLObjectBuilderFactory builderFactory, UsageType usageType, + java.security.cert.X509Certificate certificate) { + KeyDescriptor keyDescriptor = buildObject(builderFactory, KeyDescriptor.DEFAULT_ELEMENT_NAME); + KeyInfo keyInfo = buildObject(builderFactory, KeyInfo.DEFAULT_ELEMENT_NAME); + X509Certificate x509Certificate = buildObject(builderFactory, X509Certificate.DEFAULT_ELEMENT_NAME); + X509Data x509Data = buildObject(builderFactory, X509Data.DEFAULT_ELEMENT_NAME); + + try { + x509Certificate.setValue(new String(Base64.getEncoder().encode(certificate.getEncoded()))); + } catch (CertificateEncodingException e) { + throw new Saml2Exception("Cannot encode certificate " + certificate.toString()); + } + + x509Data.getX509Certificates().add(x509Certificate); + keyInfo.getX509Datas().add(x509Data); + + keyDescriptor.setUse(usageType); + keyDescriptor.setKeyInfo(keyInfo); + return keyDescriptor; + } + + private AssertionConsumerService buildAssertionConsumerService(RelyingPartyRegistration registration, + XMLObjectBuilderFactory builderFactory, HttpServletRequest request) { + AssertionConsumerService assertionConsumerService = buildObject(builderFactory, AssertionConsumerService.DEFAULT_ELEMENT_NAME); + + assertionConsumerService.setLocation( + resolveTemplate(registration.getAssertionConsumerServiceLocation(), registration, request)); + assertionConsumerService.setBinding(registration.getAssertingPartyDetails().getSingleSignOnServiceBinding().getUrn()); + assertionConsumerService.setIndex(1); + return assertionConsumerService; + } + + @SuppressWarnings("unchecked") + private T buildObject(XMLObjectBuilderFactory builderFactory, QName elementName) { + XMLObjectBuilder builder = builderFactory.getBuilder(elementName); + if (builder == null) { + throw new Saml2Exception("Cannot build object - builder not defined for element " + elementName); + } + return (T) builder.buildObject(elementName); + } + + private String resolveTemplate(String template, RelyingPartyRegistration registration, HttpServletRequest request) { + return Saml2ServletUtils.resolveUrlTemplate(template, Saml2ServletUtils.getApplicationUri(request), registration); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilter.java new file mode 100644 index 0000000000..e32c6a36d0 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilter.java @@ -0,0 +1,81 @@ +/* + * 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.web; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * This {@code Servlet} returns a generated Service Provider Metadata XML + * + * @since 5.4 + * @author Jakub Kubrynski + */ +public class Saml2MetadataFilter extends OncePerRequestFilter { + + private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + private final Saml2MetadataResolver saml2MetadataResolver; + + private RequestMatcher redirectMatcher = new AntPathRequestMatcher("/saml2/service-provider-metadata/{registrationId}"); + + public Saml2MetadataFilter(RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, Saml2MetadataResolver saml2MetadataResolver) { + this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository; + this.saml2MetadataResolver = saml2MetadataResolver; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + RequestMatcher.MatchResult matcher = this.redirectMatcher.matcher(request); + if (!matcher.isMatch()) { + filterChain.doFilter(request, response); + return; + } + + String registrationId = matcher.getVariables().get("registrationId"); + + RelyingPartyRegistration registration = relyingPartyRegistrationRepository.findByRegistrationId(registrationId); + + if (registration == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String xml = saml2MetadataResolver.resolveMetadata(request, registration); + + writeMetadataToResponse(response, registrationId, xml); + } + + private void writeMetadataToResponse(HttpServletResponse response, String registrationId, String xml) throws IOException { + response.setContentType(MediaType.APPLICATION_XML_VALUE); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"saml-" + registrationId + "-metadata.xml\""); + response.setContentLength(xml.length()); + response.getWriter().write(xml); + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataResolver.java new file mode 100644 index 0000000000..9088e43ed0 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataResolver.java @@ -0,0 +1,29 @@ +/* + * 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.web; + +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; + +import javax.servlet.http.HttpServletRequest; + +/** + * @author Jakub Kubrynski + * @since 5.4 + */ +public interface Saml2MetadataResolver { + String resolveMetadata(HttpServletRequest request, RelyingPartyRegistration registration); +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationTests.java index d8cd44acd5..e9c4f8134b 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationTests.java @@ -18,7 +18,7 @@ package org.springframework.security.saml2.provider.service.registration; import org.junit.Test; -import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; +import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationFilter; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.saml2.credentials.TestSaml2X509Credentials.relyingPartyVerifyingCredential; diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java index e71ec4b489..a508c4fc96 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/TestRelyingPartyRegistrations.java @@ -17,7 +17,7 @@ package org.springframework.security.saml2.provider.service.registration; import org.springframework.security.saml2.credentials.Saml2X509Credential; -import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; +import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationFilter; import static org.springframework.security.saml2.credentials.TestSaml2X509Credentials.relyingPartySigningCredential; import static org.springframework.security.saml2.credentials.TestSaml2X509Credentials.relyingPartyVerifyingCredential; diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/OpenSamlMetadataResolverTest.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/OpenSamlMetadataResolverTest.java new file mode 100644 index 0000000000..0313d867a6 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/OpenSamlMetadataResolverTest.java @@ -0,0 +1,67 @@ +/* + * 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.web; + +import org.junit.Before; +import org.junit.Test; +import org.opensaml.core.config.InitializationException; +import org.opensaml.core.config.InitializationService; +import org.opensaml.saml.saml2.core.NameIDType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; + +import javax.servlet.http.HttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding.REDIRECT; + +public class OpenSamlMetadataResolverTest { + + @Before + public void setUp() throws InitializationException { + InitializationService.initialize(); + } + + @Test + public void shouldGenerateMetadata() { + // given + OpenSamlMetadataResolver openSamlMetadataResolver = new OpenSamlMetadataResolver(); + RelyingPartyRegistration relyingPartyRegistration = TestRelyingPartyRegistrations.relyingPartyRegistration() + .assertingPartyDetails(p -> p.singleSignOnServiceBinding(REDIRECT)) + .assertingPartyDetails(p -> p.wantAuthnRequestsSigned(true)) + .assertingPartyDetails(p -> p.nameIdFormat(NameIDType.EMAIL)) + .build(); + HttpServletRequest servletRequestMock = new MockHttpServletRequest(); + + // when + String metadataXml = openSamlMetadataResolver.resolveMetadata(servletRequestMock, relyingPartyRegistration); + + // then + assertThat(metadataXml) + .contains("") + .contains("MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBh") + .contains("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress") + .contains("Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\"") + .contains("Location=\"http://localhost/login/saml2/sso/simplesamlphp\" index=\"1\""); + } + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilterTest.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilterTest.java new file mode 100644 index 0000000000..94da786c0c --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/web/Saml2MetadataFilterTest.java @@ -0,0 +1,114 @@ +/* + * 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.web; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations; + +import javax.servlet.FilterChain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class Saml2MetadataFilterTest { + + RelyingPartyRegistrationRepository repository; + Saml2MetadataResolver saml2MetadataResolver; + Saml2MetadataFilter filter; + MockHttpServletRequest request; + MockHttpServletResponse response; + FilterChain filterChain; + + @Before + public void setup() { + repository = mock(RelyingPartyRegistrationRepository.class); + saml2MetadataResolver = mock(Saml2MetadataResolver.class); + filter = new Saml2MetadataFilter(repository, saml2MetadataResolver); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + filterChain = mock(FilterChain.class); + } + + @Test + public void shouldReturnValueWhenMatcherSucceed() throws Exception { + // given + request.setPathInfo("/saml2/service-provider-metadata/registration-id"); + + // when + filter.doFilter(request, response, filterChain); + + // then + verifyNoInteractions(filterChain); + } + + @Test + public void shouldProcessFilterChainIfMatcherFails() throws Exception { + // given + request.setPathInfo("/saml2/authenticate/registration-id"); + + // when + filter.doFilter(request, response, filterChain); + + // then + verify(filterChain).doFilter(request, response); + } + + @Test + public void shouldReturn401IfNoRegistrationIsFound() throws Exception { + // given + request.setPathInfo("/saml2/service-provider-metadata/invalidRegistration"); + when(repository.findByRegistrationId("invalidRegistration")).thenReturn(null); + + // when + filter.doFilter(request, response, filterChain); + + // then + verifyNoInteractions(filterChain); + assertThat(response.getStatus()).isEqualTo(401); + } + + @Test + public void shouldInvokeMetadataGenerationIfRegistrationIsFound() throws Exception { + // given + request.setPathInfo("/saml2/service-provider-metadata/validRegistration"); + RelyingPartyRegistration validRegistration = TestRelyingPartyRegistrations.relyingPartyRegistration().build(); + when(repository.findByRegistrationId("validRegistration")).thenReturn(validRegistration); + + String generatedMetadata = "test"; + when(saml2MetadataResolver.resolveMetadata(request, validRegistration)).thenReturn(generatedMetadata); + + filter = new Saml2MetadataFilter(repository, saml2MetadataResolver); + + // when + filter.doFilter(request, response, filterChain); + + // then + verifyNoInteractions(filterChain); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).isEqualTo(generatedMetadata); + verify(saml2MetadataResolver).resolveMetadata(request, validRegistration); + } + +} diff --git a/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/SecurityConfig.java b/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/SecurityConfig.java index 0d6af74358..a0accf00bf 100644 --- a/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/SecurityConfig.java +++ b/samples/javaconfig/saml2login/src/main/java/org/springframework/security/samples/config/SecurityConfig.java @@ -29,7 +29,7 @@ import org.springframework.security.converter.RsaKeyConverters; import org.springframework.security.saml2.core.Saml2X509Credential; import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; -import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; +import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationFilter; import static org.springframework.security.saml2.core.Saml2X509Credential.Saml2X509CredentialType.DECRYPTION; import static org.springframework.security.saml2.core.Saml2X509Credential.Saml2X509CredentialType.SIGNING; diff --git a/samples/javaconfig/saml2login/src/test/java/org/springframework/security/samples/config/SecurityConfigTests.java b/samples/javaconfig/saml2login/src/test/java/org/springframework/security/samples/config/SecurityConfigTests.java index 0d79b19a50..df5a05ae4e 100644 --- a/samples/javaconfig/saml2login/src/test/java/org/springframework/security/samples/config/SecurityConfigTests.java +++ b/samples/javaconfig/saml2login/src/test/java/org/springframework/security/samples/config/SecurityConfigTests.java @@ -17,7 +17,7 @@ package org.springframework.security.samples.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; -import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter; +import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationFilter; import org.springframework.security.web.FilterChainProxy; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;