diff --git a/etc/nohttp/allowlist.lines b/etc/nohttp/allowlist.lines index 1b12424b16..a9898c86da 100644 --- a/etc/nohttp/allowlist.lines +++ b/etc/nohttp/allowlist.lines @@ -4,3 +4,4 @@ ^http://jaspan.com.* ^http://lists.webappsec.org/.* ^http://webblaze.cs.berkeley.edu/.* +^http://www.w3.org/2000/09/xmldsig.* diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter.java new file mode 100644 index 0000000000..97ee941fe4 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter.java @@ -0,0 +1,201 @@ +/* + * 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.registration; + +import java.io.IOException; +import java.io.InputStream; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import net.shibboleth.utilities.java.support.xml.ParserPool; +import org.opensaml.core.config.ConfigurationService; +import org.opensaml.core.xml.config.XMLObjectProviderRegistry; +import org.opensaml.saml.saml2.metadata.EntityDescriptor; +import org.opensaml.saml.saml2.metadata.IDPSSODescriptor; +import org.opensaml.saml.saml2.metadata.KeyDescriptor; +import org.opensaml.saml.saml2.metadata.SingleSignOnService; +import org.opensaml.saml.saml2.metadata.impl.EntityDescriptorUnmarshaller; +import org.opensaml.security.credential.UsageType; +import org.opensaml.xmlsec.keyinfo.KeyInfoSupport; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.OpenSamlInitializationService; +import org.springframework.security.saml2.core.Saml2X509Credential; + +import static java.lang.Boolean.TRUE; +import static org.opensaml.saml.common.xml.SAMLConstants.SAML20P_NS; +import static org.springframework.security.saml2.core.Saml2X509Credential.encryption; +import static org.springframework.security.saml2.core.Saml2X509Credential.verification; +import static org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration.withRegistrationId; + +/** + * An {@link HttpMessageConverter} that takes an {@code IDPSSODescriptor} in an HTTP response + * and converts it into a {@link RelyingPartyRegistration.Builder}. + * + * The primary use case for this is constructing a {@link RelyingPartyRegistration} for inclusion in a + * {@link RelyingPartyRegistrationRepository}. To do so, you can include an instance of this converter in a + * {@link org.springframework.web.client.RestOperations} like so: + * + *
+ * 		RestOperations rest = new RestTemplate(Collections.singletonList(
+ *     			new RelyingPartyRegistrationsBuilderHttpMessageConverter()));
+ * 		RelyingPartyRegistration.Builder builder = rest.getForObject
+ * 				("https://idp.example.org/metadata", RelyingPartyRegistration.Builder.class);
+ * 		RelyingPartyRegistration registration = builder.registrationId("registration-id").build();
+ * 
+ * + * Note that this will only configure the asserting party (IDP) half of the {@link RelyingPartyRegistration}, + * meaning where and how to send AuthnRequests, how to verify Assertions, etc. + * + * To further configure the {@link RelyingPartyRegistration} with relying party (SP) information, you may + * invoke the appropriate methods on the builder. + * + * @author Josh Cummings + * @since 5.4 + */ +public class OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter + implements HttpMessageConverter { + + static { + OpenSamlInitializationService.initialize(); + } + + private final EntityDescriptorUnmarshaller unmarshaller; + private final ParserPool parserPool; + + /** + * Creates a {@link OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter} + */ + public OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter() { + XMLObjectProviderRegistry registry = ConfigurationService.get(XMLObjectProviderRegistry.class); + this.unmarshaller = (EntityDescriptorUnmarshaller) registry.getUnmarshallerFactory() + .getUnmarshaller(EntityDescriptor.DEFAULT_ELEMENT_NAME); + this.parserPool = registry.getParserPool(); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean canRead(Class clazz, MediaType mediaType) { + return RelyingPartyRegistration.Builder.class.isAssignableFrom(clazz); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public List getSupportedMediaTypes() { + return Arrays.asList(MediaType.APPLICATION_XML, MediaType.TEXT_XML); + } + + /** + * {@inheritDoc} + */ + @Override + public RelyingPartyRegistration.Builder read(Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + EntityDescriptor descriptor = entityDescriptor(inputMessage.getBody()); + IDPSSODescriptor idpssoDescriptor = descriptor.getIDPSSODescriptor(SAML20P_NS); + if (idpssoDescriptor == null) { + throw new Saml2Exception("Metadata response is missing the necessary IDPSSODescriptor element"); + } + List verification = new ArrayList<>(); + List encryption = new ArrayList<>(); + for (KeyDescriptor keyDescriptor : idpssoDescriptor.getKeyDescriptors()) { + if (keyDescriptor.getUse().equals(UsageType.SIGNING)) { + List certificates = certificates(keyDescriptor); + for (X509Certificate certificate : certificates) { + verification.add(verification(certificate)); + } + } + if (keyDescriptor.getUse().equals(UsageType.ENCRYPTION)) { + List certificates = certificates(keyDescriptor); + for (X509Certificate certificate : certificates) { + encryption.add(encryption(certificate)); + } + } + } + if (verification.isEmpty()) { + throw new Saml2Exception("Metadata response is missing verification certificates, necessary for verifying SAML assertions"); + } + RelyingPartyRegistration.Builder builder = withRegistrationId(descriptor.getEntityID()) + .assertingPartyDetails(party -> party + .entityId(descriptor.getEntityID()) + .wantAuthnRequestsSigned(TRUE.equals(idpssoDescriptor.getWantAuthnRequestsSigned())) + .verificationX509Credentials(c -> c.addAll(verification)) + .encryptionX509Credentials(c -> c.addAll(encryption))); + for (SingleSignOnService singleSignOnService : idpssoDescriptor.getSingleSignOnServices()) { + Saml2MessageBinding binding; + if (singleSignOnService.getBinding().equals(Saml2MessageBinding.POST.getUrn())) { + binding = Saml2MessageBinding.POST; + } else if (singleSignOnService.getBinding().equals(Saml2MessageBinding.REDIRECT.getUrn())) { + binding = Saml2MessageBinding.REDIRECT; + } else { + continue; + } + builder.assertingPartyDetails(party -> party + .singleSignOnServiceLocation(singleSignOnService.getLocation()) + .singleSignOnServiceBinding(binding)); + return builder; + } + throw new Saml2Exception("Metadata response is missing a SingleSignOnService, necessary for sending AuthnRequests"); + } + + private List certificates(KeyDescriptor keyDescriptor) { + try { + return KeyInfoSupport.getCertificates(keyDescriptor.getKeyInfo()); + } catch (CertificateException e) { + throw new Saml2Exception(e); + } + } + + private EntityDescriptor entityDescriptor(InputStream inputStream) { + try { + Document document = this.parserPool.parse(inputStream); + Element element = document.getDocumentElement(); + return (EntityDescriptor) this.unmarshaller.unmarshall(element); + } catch (Exception e) { + throw new Saml2Exception(e); + } + } + + @Override + public void write(RelyingPartyRegistration.Builder builder, MediaType contentType, HttpOutputMessage outputMessage) throws HttpMessageNotWritableException { + throw new HttpMessageNotWritableException("This converter cannot write a RelyingPartyRegistration.Builder"); + } +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests.java new file mode 100644 index 0000000000..270183c17b --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests.java @@ -0,0 +1,135 @@ +/* + * 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.registration; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Base64; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.security.saml2.Saml2Exception; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.springframework.http.HttpStatus.OK; + +public class OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests { + private static final String CERTIFICATE = + "MIIEEzCCAvugAwIBAgIJAIc1qzLrv+5nMA0GCSqGSIb3DQEBCwUAMIGfMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ08xFDASBgNVBAcMC0Nhc3RsZSBSb2NrMRwwGgYDVQQKDBNTYW1sIFRlc3RpbmcgU2VydmVyMQswCQYDVQQLDAJJVDEgMB4GA1UEAwwXc2ltcGxlc2FtbHBocC5jZmFwcHMuaW8xIDAeBgkqhkiG9w0BCQEWEWZoYW5pa0BwaXZvdGFsLmlvMB4XDTE1MDIyMzIyNDUwM1oXDTI1MDIyMjIyNDUwM1owgZ8xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDTzEUMBIGA1UEBwwLQ2FzdGxlIFJvY2sxHDAaBgNVBAoME1NhbWwgVGVzdGluZyBTZXJ2ZXIxCzAJBgNVBAsMAklUMSAwHgYDVQQDDBdzaW1wbGVzYW1scGhwLmNmYXBwcy5pbzEgMB4GCSqGSIb3DQEJARYRZmhhbmlrQHBpdm90YWwuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4cn62E1xLqpN34PmbrKBbkOXFjzWgJ9b+pXuaRft6A339uuIQeoeH5qeSKRVTl32L0gdz2ZivLwZXW+cqvftVW1tvEHvzJFyxeTW3fCUeCQsebLnA2qRa07RkxTo6Nf244mWWRDodcoHEfDUSbxfTZ6IExSojSIU2RnD6WllYWFdD1GFpBJOmQB8rAc8wJIBdHFdQnX8Ttl7hZ6rtgqEYMzYVMuJ2F2r1HSU1zSAvwpdYP6rRGFRJEfdA9mm3WKfNLSc5cljz0X/TXy0vVlAV95l9qcfFzPmrkNIst9FZSwpvB49LyAVke04FQPPwLgVH4gphiJH3jvZ7I+J5lS8VAgMBAAGjUDBOMB0GA1UdDgQWBBTTyP6Cc5HlBJ5+ucVCwGc5ogKNGzAfBgNVHSMEGDAWgBTTyP6Cc5HlBJ5+ucVCwGc5ogKNGzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAvMS4EQeP/ipV4jOG5lO6/tYCb/iJeAduOnRhkJk0DbX329lDLZhTTL/x/w/9muCVcvLrzEp6PN+VWfw5E5FWtZN0yhGtP9R+vZnrV+oc2zGD+no1/ySFOe3EiJCO5dehxKjYEmBRv5sU/LZFKZpozKN/BMEa6CqLuxbzb7ykxVr7EVFXwltPxzE9TmL9OACNNyF5eJHWMRMllarUvkcXlh4pux4ks9e6zV9DQBy2zds9f1I3qxg0eX6JnGrXi/ZiCT+lJgVe3ZFXiejiLAiKB04sXW3ti0LW3lx13Y1YlQ4/tlpgTgfIJxKV6nyPiLoK0nywbMd+vpAirDt2Oc+hk"; + + private static final String ENTITY_DESCRIPTOR_TEMPLATE = + "\n" + + "\n%s" + + ""; + private static final String IDP_SSO_DESCRIPTOR_TEMPLATE = + "\n" + + "%s\n" + + ""; + private static final String KEY_DESCRIPTOR_TEMPLATE = + "\n" + + "\n" + + "\n" + + "" + CERTIFICATE + "\n" + + "\n" + + "\n" + + ""; + private static final String SINGLE_SIGN_ON_SERVICE_TEMPLATE = + ""; + + private OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter converter; + + @Before + public void setup() { + this.converter = new OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter(); + } + + @Test + public void readWhenMissingIDPSSODescriptorThenException() { + MockClientHttpResponse response = new MockClientHttpResponse + ((String.format(ENTITY_DESCRIPTOR_TEMPLATE, "")).getBytes(), OK); + assertThatCode(() -> this.converter.read(RelyingPartyRegistration.Builder.class, response)) + .isInstanceOf(Saml2Exception.class) + .hasMessageContaining("Metadata response is missing the necessary IDPSSODescriptor element"); + } + + @Test + public void readWhenMissingVerificationKeyThenException() { + String payload = String.format(ENTITY_DESCRIPTOR_TEMPLATE, + String.format(IDP_SSO_DESCRIPTOR_TEMPLATE, "")); + MockClientHttpResponse response = new MockClientHttpResponse(payload.getBytes(), OK); + assertThatCode(() -> this.converter.read(RelyingPartyRegistration.Builder.class, response)) + .isInstanceOf(Saml2Exception.class) + .hasMessageContaining("Metadata response is missing verification certificates, necessary for verifying SAML assertions"); + } + + @Test + public void readWhenMissingSingleSignOnServiceThenException() { + String payload = String.format(ENTITY_DESCRIPTOR_TEMPLATE, + String.format(IDP_SSO_DESCRIPTOR_TEMPLATE, + String.format(KEY_DESCRIPTOR_TEMPLATE, "signing") + )); + MockClientHttpResponse response = new MockClientHttpResponse(payload.getBytes(), OK); + assertThatCode(() -> this.converter.read(RelyingPartyRegistration.Builder.class, response)) + .isInstanceOf(Saml2Exception.class) + .hasMessageContaining("Metadata response is missing a SingleSignOnService, necessary for sending AuthnRequests"); + } + + @Test + public void readWhenDescriptorFullySpecifiedThenConfigures() throws Exception { + String payload = String.format(ENTITY_DESCRIPTOR_TEMPLATE, + String.format(IDP_SSO_DESCRIPTOR_TEMPLATE, + String.format(KEY_DESCRIPTOR_TEMPLATE, "signing") + + String.format(KEY_DESCRIPTOR_TEMPLATE, "encryption") + + String.format(SINGLE_SIGN_ON_SERVICE_TEMPLATE) + )); + MockClientHttpResponse response = new MockClientHttpResponse(payload.getBytes(), OK); + RelyingPartyRegistration registration = + this.converter.read(RelyingPartyRegistration.Builder.class, response) + .registrationId("one") + .build(); + RelyingPartyRegistration.AssertingPartyDetails details = + registration.getAssertingPartyDetails(); + assertThat(details.getWantAuthnRequestsSigned()).isFalse(); + assertThat(details.getSingleSignOnServiceLocation()).isEqualTo("sso-location"); + assertThat(details.getSingleSignOnServiceBinding()).isEqualTo(Saml2MessageBinding.REDIRECT); + assertThat(details.getEntityId()).isEqualTo("entity-id"); + assertThat(details.getVerificationX509Credentials()).hasSize(1); + assertThat(details.getVerificationX509Credentials().iterator().next().getCertificate()) + .isEqualTo(x509Certificate(CERTIFICATE)); + assertThat(details.getEncryptionX509Credentials()).hasSize(1); + assertThat(details.getEncryptionX509Credentials().iterator().next().getCertificate()) + .isEqualTo(x509Certificate(CERTIFICATE)); + } + + X509Certificate x509Certificate(String data) { + try { + InputStream certificate = new ByteArrayInputStream(Base64.getDecoder().decode(data.getBytes())); + return (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(certificate); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } +}