3 changed files with 337 additions and 0 deletions
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue