Add OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter

Closes gh-8877
This commit is contained in:
Josh Cummings 2020-07-23 15:32:22 -06:00
parent 2276fcf34a
commit 77128a94e2
No known key found for this signature in database
GPG Key ID: 49EF60DD7FF83443
3 changed files with 337 additions and 0 deletions

View File

@ -4,3 +4,4 @@
^http://jaspan.com.*
^http://lists.webappsec.org/.*
^http://webblaze.cs.berkeley.edu/.*
^http://www.w3.org/2000/09/xmldsig.*

View File

@ -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:
*
* <pre>
* 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();
* </pre>
*
* 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<RelyingPartyRegistration.Builder> {
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<MediaType> getSupportedMediaTypes() {
return Arrays.asList(MediaType.APPLICATION_XML, MediaType.TEXT_XML);
}
/**
* {@inheritDoc}
*/
@Override
public RelyingPartyRegistration.Builder read(Class<? extends RelyingPartyRegistration.Builder> 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<Saml2X509Credential> verification = new ArrayList<>();
List<Saml2X509Credential> encryption = new ArrayList<>();
for (KeyDescriptor keyDescriptor : idpssoDescriptor.getKeyDescriptors()) {
if (keyDescriptor.getUse().equals(UsageType.SIGNING)) {
List<X509Certificate> certificates = certificates(keyDescriptor);
for (X509Certificate certificate : certificates) {
verification.add(verification(certificate));
}
}
if (keyDescriptor.getUse().equals(UsageType.ENCRYPTION)) {
List<X509Certificate> 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<X509Certificate> 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");
}
}

View File

@ -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 =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\" " +
"entityID=\"entity-id\" " +
"ID=\"_bf133aac099b99b3d81286e1a341f2d34188043a77fe15bf4bf1487dae9b2ea3\">\n%s" +
"</md:EntityDescriptor>";
private static final String IDP_SSO_DESCRIPTOR_TEMPLATE =
"<md:IDPSSODescriptor protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n" +
"%s\n" +
"</md:IDPSSODescriptor>";
private static final String KEY_DESCRIPTOR_TEMPLATE =
"<md:KeyDescriptor use=\"%s\">\n" +
"<ds:KeyInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\">\n" +
"<ds:X509Data>\n" +
"<ds:X509Certificate>" + CERTIFICATE + "</ds:X509Certificate>\n" +
"</ds:X509Data>\n" +
"</ds:KeyInfo>\n" +
"</md:KeyDescriptor>";
private static final String SINGLE_SIGN_ON_SERVICE_TEMPLATE =
"<md:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" " +
"Location=\"sso-location\"/>";
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);
}
}
}