14 changed files with 518 additions and 20 deletions
@ -0,0 +1,161 @@
@@ -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<KeyDescriptor> buildKeys(XMLObjectBuilderFactory builderFactory, |
||||
List<Saml2X509Credential> credentials, UsageType usageType) { |
||||
List<KeyDescriptor> 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> 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,81 @@
@@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,29 @@
@@ -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); |
||||
} |
||||
@ -0,0 +1,67 @@
@@ -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("<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,0 +1,114 @@
@@ -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 = "<xml>test</xml>"; |
||||
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); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue