Skip to content

Commit

Permalink
Fix spring-projects#8693 Support SAML 2.0 SP Metadata Endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
jkubrynski committed Jul 12, 2020
1 parent 793926b commit 89e48df
Show file tree
Hide file tree
Showing 7 changed files with 440 additions and 15 deletions.
Expand Up @@ -73,6 +73,9 @@ final class FilterComparator implements Comparator<Filter>, Serializable {
filterToOrder.put(
"org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter",
order.next());
filterToOrder.put(
"org.springframework.security.saml2.provider.service.servlet.filter.SamlServiceProviderMetadataFilter",
order.next());
filterToOrder.put(
"org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter",
order.next());
Expand Down
Expand Up @@ -35,6 +35,7 @@
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter;
import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter;
import org.springframework.security.saml2.provider.service.servlet.filter.SamlServiceProviderMetadataFilter;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
Expand Down Expand Up @@ -107,6 +108,8 @@ public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>> extend

private Saml2WebSsoAuthenticationFilter saml2WebSsoAuthenticationFilter;

private SamlServiceProviderMetadataFilter samlServiceProviderMetadataFilter;

/**
* Allows a configuration of a {@link AuthenticationManager} to be used during SAML 2 authentication.
* If none is specified, the system will create one inject it into the {@link Saml2WebSsoAuthenticationFilter}
Expand Down Expand Up @@ -190,6 +193,10 @@ public void init(B http) throws Exception {
setAuthenticationFilter(saml2WebSsoAuthenticationFilter);
super.loginProcessingUrl(this.loginProcessingUrl);

samlServiceProviderMetadataFilter = new SamlServiceProviderMetadataFilter(
this.relyingPartyRegistrationRepository
);

if (hasText(this.loginPage)) {
// Set custom login page
super.loginPage(this.loginPage);
Expand Down Expand Up @@ -229,6 +236,7 @@ public void init(B http) throws Exception {
@Override
public void configure(B http) throws Exception {
http.addFilter(this.authenticationRequestEndpoint.build(http));
http.addFilter(samlServiceProviderMetadataFilter);
super.configure(http);
if (this.authenticationManager == null) {
registerDefaultAuthenticationProvider(http);
Expand Down
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
@@ -0,0 +1,160 @@
/*
* 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.servlet.filter;

import net.shibboleth.utilities.java.support.xml.SerializeSupport;
import org.opensaml.core.config.ConfigurationService;
import org.opensaml.core.xml.XMLObjectBuilder;
import org.opensaml.core.xml.XMLObjectBuilderFactory;
import org.opensaml.core.xml.config.XMLObjectProviderRegistry;
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.common.xml.SAMLConstants;
import org.opensaml.saml.saml2.core.NameIDType;
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.w3c.dom.Element;

import javax.servlet.http.HttpServletRequest;
import javax.xml.namespace.QName;
import java.security.cert.CertificateEncodingException;
import java.util.Base64;
import java.util.List;
import java.util.stream.Collectors;

/**
* @author Jakub Kubrynski
* @since 5.4
*/
class SamlMetadataGenerator {

String generateMetadata(RelyingPartyRegistration registration, HttpServletRequest request) {

XMLObjectBuilderFactory builderFactory = ConfigurationService.get(XMLObjectProviderRegistry.class).getBuilderFactory();

EntityDescriptor entityDescriptor = buildObject(builderFactory, EntityDescriptor.ELEMENT_QNAME);

entityDescriptor.setEntityID(
resolveTemplate(registration.getLocalEntityIdTemplate(), registration, request));

SPSSODescriptor spSsoDescriptor = buildSpSsoDescriptor(registration, builderFactory, request);
entityDescriptor.getRoleDescriptors(SPSSODescriptor.DEFAULT_ELEMENT_NAME).add(spSsoDescriptor);

return serializeToXmlString(entityDescriptor);
}

private String serializeToXmlString(EntityDescriptor entityDescriptor) {
try {
Marshaller marshaller = XMLObjectProviderRegistrySupport.getMarshallerFactory().getMarshaller(entityDescriptor);
if (marshaller == null) {
throw new Saml2Exception("Unable to resolve Marshaller");
}
Element element = marshaller.marshall(entityDescriptor);
return SerializeSupport.prettyPrintXML(element);
} catch (MarshallingException e) {
throw new Saml2Exception(e);
}
}

private SPSSODescriptor buildSpSsoDescriptor(RelyingPartyRegistration registration,
XMLObjectBuilderFactory builderFactory, HttpServletRequest request) {

SPSSODescriptor spSsoDescriptor = buildObject(builderFactory, SPSSODescriptor.DEFAULT_ELEMENT_NAME);
spSsoDescriptor.setAuthnRequestsSigned(registration.getProviderDetails().isSignAuthNRequest());
spSsoDescriptor.setWantAssertionsSigned(true);
spSsoDescriptor.addSupportedProtocol(SAMLConstants.SAML20P_NS);

NameIDFormat nameIdFormat = buildObject(builderFactory, NameIDFormat.DEFAULT_ELEMENT_NAME);
nameIdFormat.setFormat(NameIDType.EMAIL);
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<KeyDescriptor> buildKeys(XMLObjectBuilderFactory builderFactory,
List<Saml2X509Credential> credentials, UsageType usageType) {
return credentials
.stream()
.map(credential -> buildKeyDescriptor(builderFactory, usageType, credential.getCertificate()))
.collect(Collectors.toList());
}

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.getAssertionConsumerServiceUrlTemplate(), registration, request));
assertionConsumerService.setBinding(registration.getProviderDetails().getBinding().getUrn());
assertionConsumerService.setIndex(1);
return assertionConsumerService;
}

@SuppressWarnings("unchecked")
private <T> 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);
}

}
@@ -0,0 +1,89 @@
/*
* 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.servlet.filter;

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.ServletOutputStream;
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 SamlServiceProviderMetadataFilter extends OncePerRequestFilter {

private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
private final SamlMetadataGenerator samlMetadataGenerator;

private RequestMatcher redirectMatcher = new AntPathRequestMatcher("/saml2/service-provider-metadata/{registrationId}");

public SamlServiceProviderMetadataFilter(RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
this(relyingPartyRegistrationRepository, new SamlMetadataGenerator());
}

SamlServiceProviderMetadataFilter(RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, SamlMetadataGenerator samlMetadataGenerator) {
this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository;
this.samlMetadataGenerator = samlMetadataGenerator;
}

@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(404);
return;
}

String xml = samlMetadataGenerator.generateMetadata(registration, request);

writeMetadataToResponse(response, registrationId, xml);
}

private void writeMetadataToResponse(HttpServletResponse response, String registrationId, String xml) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"saml-" + registrationId + "-metadata.xml\"");
response.setContentLength(xml.length());
ServletOutputStream outputStream = response.getOutputStream();
outputStream.print(xml);
outputStream.flush();
outputStream.close();
}

}
@@ -0,0 +1,64 @@
/*
* 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.servlet.filter;

import org.junit.Before;
import org.junit.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.saml2.provider.service.authentication.OpenSamlAuthenticationRequestFactory;
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 SamlMetadataGeneratorTest {

@Before
public void setUp() {
new OpenSamlAuthenticationRequestFactory(); // ensure OpenSaml is bootstraped
}

@Test
public void shouldGenerateMetadata() {
// given
SamlMetadataGenerator samlMetadataGenerator = new SamlMetadataGenerator();
RelyingPartyRegistration relyingPartyRegistration = TestRelyingPartyRegistrations.relyingPartyRegistration()
.providerDetails(p -> p.binding(REDIRECT))
.providerDetails(p -> p.signAuthNRequest(true))
.build();
HttpServletRequest servletRequestMock = new MockHttpServletRequest();

// when
String metadataXml = samlMetadataGenerator.generateMetadata(relyingPartyRegistration, servletRequestMock);

// then
assertThat(metadataXml)
.contains("<EntityDescriptor")
.contains("entityID=\"http://localhost/saml2/service-provider-metadata/simplesamlphp\"")
.contains("AuthnRequestsSigned=\"true\"")
.contains("WantAssertionsSigned=\"true\"")
.contains("<md:KeyDescriptor use=\"signing\">")
.contains("<ds:X509Certificate>MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBh")
.contains("<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>")
.contains("Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\"")
.contains("Location=\"http://localhost/login/saml2/sso/simplesamlphp\" index=\"1\"");
}

}

0 comments on commit 89e48df

Please sign in to comment.