Repository: syncope Updated Branches: refs/heads/2_0_X fc682bf5b -> 4dcc0db48 refs/heads/master e3467bf4b -> e624f369d
[SYNCOPE-1185] SAMLSSOResponseValidator in action Project: http://git-wip-us.apache.org/repos/asf/syncope/repo Commit: http://git-wip-us.apache.org/repos/asf/syncope/commit/4dcc0db4 Tree: http://git-wip-us.apache.org/repos/asf/syncope/tree/4dcc0db4 Diff: http://git-wip-us.apache.org/repos/asf/syncope/diff/4dcc0db4 Branch: refs/heads/2_0_X Commit: 4dcc0db48dfa1bf9231038d6b0837e2d21bef7a8 Parents: fc682bf Author: Francesco Chicchiriccò <ilgro...@apache.org> Authored: Thu Aug 3 12:51:50 2017 +0200 Committer: Francesco Chicchiriccò <ilgro...@apache.org> Committed: Thu Aug 3 12:51:50 2017 +0200 ---------------------------------------------------------------------- .../saml2lsp/agent/AbstractSAML2SPServlet.java | 10 +- .../ext/saml2lsp/agent/AssertionConsumer.java | 7 +- .../syncope/ext/saml2lsp/agent/Logout.java | 6 +- .../common/lib/to/SAML2ReceivedResponseTO.java | 31 ++ .../saml/sso/SAMLSSOResponseValidator.java | 369 +++++++++++++++++++ .../apache/syncope/core/logic/SAML2SPLogic.java | 33 +- .../core/logic/saml2/SAML2ReaderWriter.java | 25 +- fit/core-reference/pom.xml | 2 - .../apache/syncope/fit/core/SAML2ITCase.java | 73 ++-- .../src/test/resources/stsrealm_a.jks | Bin 2061 -> 0 bytes 10 files changed, 497 insertions(+), 59 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AbstractSAML2SPServlet.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AbstractSAML2SPServlet.java b/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AbstractSAML2SPServlet.java index d84bcd7..ecc14fe 100644 --- a/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AbstractSAML2SPServlet.java +++ b/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AbstractSAML2SPServlet.java @@ -76,7 +76,12 @@ public abstract class AbstractSAML2SPServlet extends HttpServlet { } } - protected SAML2ReceivedResponseTO extract(final InputStream response) throws IOException { + protected SAML2ReceivedResponseTO extract( + final String spEntityID, + final String urlContext, + final String clientAddress, + final InputStream response) throws IOException { + String strForm = IOUtils.toString(response); MultivaluedMap<String, String> params = JAXRSUtils.getStructuredParams(strForm, "&", false, false); @@ -90,6 +95,9 @@ public abstract class AbstractSAML2SPServlet extends HttpServlet { LOG.debug("Received Relay State: {}", relayState); SAML2ReceivedResponseTO receivedResponseTO = new SAML2ReceivedResponseTO(); + receivedResponseTO.setSpEntityID(spEntityID); + receivedResponseTO.setUrlContext(urlContext); + receivedResponseTO.setClientAddress(clientAddress); receivedResponseTO.setSamlResponse(samlResponse); receivedResponseTO.setRelayState(relayState); return receivedResponseTO; http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AssertionConsumer.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AssertionConsumer.java b/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AssertionConsumer.java index 698aa7f..a2fec3a 100644 --- a/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AssertionConsumer.java +++ b/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/AssertionConsumer.java @@ -25,6 +25,7 @@ import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; import org.apache.syncope.client.lib.SyncopeClient; import org.apache.syncope.common.lib.to.SAML2LoginResponseTO; import org.apache.syncope.common.rest.api.service.SAML2SPService; @@ -42,7 +43,11 @@ public class AssertionConsumer extends AbstractSAML2SPServlet { getAttribute(Constants.SYNCOPE_ANONYMOUS_CLIENT); try { SAML2LoginResponseTO responseTO = anonymous.getService(SAML2SPService.class). - validateLoginResponse(extract(request.getInputStream())); + validateLoginResponse(extract( + StringUtils.substringBefore(request.getRequestURL().toString(), "/saml2sp"), + "saml2sp", + request.getRemoteAddr(), + request.getInputStream())); request.getSession(true).setAttribute(Constants.SAML2SPJWT, responseTO.getAccessToken()); request.getSession(true).setAttribute(Constants.SAML2SPJWT_EXPIRE, responseTO.getAccessTokenExpiryTime()); http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/Logout.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/Logout.java b/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/Logout.java index a8fe481..8008b0c 100644 --- a/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/Logout.java +++ b/ext/saml2sp/agent/src/main/java/org/apache/syncope/ext/saml2lsp/agent/Logout.java @@ -125,7 +125,11 @@ public class Logout extends AbstractSAML2SPServlet { throws ServletException, IOException { // process POST binding logout response - SAML2ReceivedResponseTO receivedResponse = extract(request.getInputStream()); + SAML2ReceivedResponseTO receivedResponse = extract( + StringUtils.substringBefore(request.getRequestURL().toString(), "/saml2sp"), + "saml2sp", + request.getRemoteAddr(), + request.getInputStream()); doLogout(receivedResponse, request, response); } http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2ReceivedResponseTO.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2ReceivedResponseTO.java b/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2ReceivedResponseTO.java index 3d5d9b4..4ea2dec 100644 --- a/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2ReceivedResponseTO.java +++ b/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2ReceivedResponseTO.java @@ -20,6 +20,7 @@ package org.apache.syncope.common.lib.to; import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlType; +import org.apache.commons.lang3.StringUtils; import org.apache.syncope.common.lib.AbstractBaseBean; @XmlRootElement(name = "saml2ReceivedResponse") @@ -28,10 +29,40 @@ public class SAML2ReceivedResponseTO extends AbstractBaseBean { private static final long serialVersionUID = 6102419133516694822L; + private String spEntityID; + + private String urlContext; + + private String clientAddress; + private String samlResponse; private String relayState; + public String getSpEntityID() { + return spEntityID; + } + + public void setSpEntityID(final String spEntityID) { + this.spEntityID = StringUtils.appendIfMissing(spEntityID, "/"); + } + + public String getUrlContext() { + return urlContext; + } + + public void setUrlContext(final String urlContext) { + this.urlContext = urlContext; + } + + public String getClientAddress() { + return clientAddress; + } + + public void setClientAddress(final String clientAddress) { + this.clientAddress = clientAddress; + } + public String getSamlResponse() { return samlResponse; } http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/ext/saml2sp/logic/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java b/ext/saml2sp/logic/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java new file mode 100644 index 0000000..bff1fd8 --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java @@ -0,0 +1,369 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.cxf.rs.security.saml.sso; + +import java.util.Date; +import java.util.List; +import java.util.logging.Logger; + +import org.w3c.dom.Element; + +import org.apache.cxf.common.logging.LogUtils; +import org.apache.wss4j.common.ext.WSSecurityException; +import org.apache.wss4j.common.saml.builder.SAML2Constants; +import org.apache.wss4j.common.util.DOM2Writer; +import org.opensaml.saml.saml2.core.AudienceRestriction; +import org.opensaml.saml.saml2.core.AuthnStatement; + +/** + * Validate a SAML 2.0 Protocol Response according to the Web SSO profile. The Response + * should be validated by the SAMLProtocolResponseValidator first. + */ +//CHECKSTYLE:OFF +public class SAMLSSOResponseValidator { + + private static final Logger LOG = LogUtils.getL7dLogger(SAMLSSOResponseValidator.class); + + private String issuerIDP; + private String assertionConsumerURL; + private String clientAddress; + private String requestId; + private String spIdentifier; + private boolean enforceResponseSigned; + private boolean enforceAssertionsSigned = true; + private boolean enforceKnownIssuer = true; + private TokenReplayCache<String> replayCache; + + /** + * Enforce that Assertions contained in the Response must be signed (if the Response itself is not + * signed). The default is true. + */ + public void setEnforceAssertionsSigned(boolean enforceAssertionsSigned) { + this.enforceAssertionsSigned = enforceAssertionsSigned; + } + + /** + * Enforce that the Issuer of the received Response/Assertion is known. The default is true. + */ + public void setEnforceKnownIssuer(boolean enforceKnownIssuer) { + this.enforceKnownIssuer = enforceKnownIssuer; + } + + /** + * Validate a SAML 2 Protocol Response + * @param samlResponse + * @param postBinding + * @return a SSOValidatorResponse object + * @throws WSSecurityException + */ + public SSOValidatorResponse validateSamlResponse( + org.opensaml.saml.saml2.core.Response samlResponse, + boolean postBinding + ) throws WSSecurityException { + // Check the Issuer + validateIssuer(samlResponse.getIssuer()); + + // The Response must contain at least one Assertion. + if (samlResponse.getAssertions() == null || samlResponse.getAssertions().isEmpty()) { + LOG.fine("The Response must contain at least one Assertion"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // The Response must contain a Destination that matches the assertionConsumerURL if it is + // signed + String destination = samlResponse.getDestination(); + if (samlResponse.isSigned() + && (destination == null || !destination.equals(assertionConsumerURL))) { + LOG.fine("The Response must contain a destination that matches the assertion consumer URL"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + if (enforceResponseSigned && !samlResponse.isSigned()) { + LOG.fine("The Response must be signed!"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // Validate Assertions + org.opensaml.saml.saml2.core.Assertion validAssertion = null; + Date sessionNotOnOrAfter = null; + for (org.opensaml.saml.saml2.core.Assertion assertion : samlResponse.getAssertions()) { + // Check the Issuer + if (assertion.getIssuer() == null) { + LOG.fine("Assertion Issuer must not be null"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + validateIssuer(assertion.getIssuer()); + + if (!samlResponse.isSigned() && enforceAssertionsSigned && assertion.getSignature() == null) { + LOG.fine("The enclosed assertions in the SAML Response must be signed"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // Check for AuthnStatements and validate the Subject accordingly + if (assertion.getAuthnStatements() != null + && !assertion.getAuthnStatements().isEmpty()) { + org.opensaml.saml.saml2.core.Subject subject = assertion.getSubject(); + org.opensaml.saml.saml2.core.SubjectConfirmation subjectConf = + validateAuthenticationSubject(subject, assertion.getID(), postBinding); + if (subjectConf != null) { + validateAudienceRestrictionCondition(assertion.getConditions()); + validAssertion = assertion; + // Store Session NotOnOrAfter + for (AuthnStatement authnStatment : assertion.getAuthnStatements()) { + if (authnStatment.getSessionNotOnOrAfter() != null) { + sessionNotOnOrAfter = authnStatment.getSessionNotOnOrAfter().toDate(); + } + } + // Fall back to the SubjectConfirmationData NotOnOrAfter if we have no session NotOnOrAfter + if (sessionNotOnOrAfter == null) { + sessionNotOnOrAfter = subjectConf.getSubjectConfirmationData().getNotOnOrAfter().toDate(); + } + } + } + } + + if (validAssertion == null) { + LOG.fine("The Response did not contain any Authentication Statement that matched " + + "the Subject Confirmation criteria"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + SSOValidatorResponse validatorResponse = new SSOValidatorResponse(); + validatorResponse.setResponseId(samlResponse.getID()); + validatorResponse.setSessionNotOnOrAfter(sessionNotOnOrAfter); + if (samlResponse.getIssueInstant() != null) { + validatorResponse.setCreated(samlResponse.getIssueInstant().toDate()); + } + + Element assertionElement = validAssertion.getDOM(); + Element clonedAssertionElement = (Element)assertionElement.cloneNode(true); + validatorResponse.setAssertionElement(clonedAssertionElement); + validatorResponse.setAssertion(DOM2Writer.nodeToString(clonedAssertionElement)); + + return validatorResponse; + } + + /** + * Validate the Issuer (if it exists) + */ + private void validateIssuer(org.opensaml.saml.saml2.core.Issuer issuer) throws WSSecurityException { + if (issuer == null) { + return; + } + + // Issuer value must match (be contained in) Issuer IDP + if (enforceKnownIssuer && !issuerIDP.startsWith(issuer.getValue())) { + LOG.fine("Issuer value: " + issuer.getValue() + " does not match issuer IDP: " + + issuerIDP); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // Format must be nameid-format-entity + if (issuer.getFormat() != null + && !SAML2Constants.NAMEID_FORMAT_ENTITY.equals(issuer.getFormat())) { + LOG.fine("Issuer format is not null and does not equal: " + + SAML2Constants.NAMEID_FORMAT_ENTITY); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + } + + /** + * Validate the Subject (of an Authentication Statement). + */ + private org.opensaml.saml.saml2.core.SubjectConfirmation validateAuthenticationSubject( + org.opensaml.saml.saml2.core.Subject subject, String id, boolean postBinding + ) throws WSSecurityException { + if (subject.getSubjectConfirmations() == null) { + return null; + } + + org.opensaml.saml.saml2.core.SubjectConfirmation validSubjectConf = null; + // We need to find a Bearer Subject Confirmation method + for (org.opensaml.saml.saml2.core.SubjectConfirmation subjectConf + : subject.getSubjectConfirmations()) { + if (SAML2Constants.CONF_BEARER.equals(subjectConf.getMethod())) { + validateSubjectConfirmation(subjectConf.getSubjectConfirmationData(), id, postBinding); + validSubjectConf = subjectConf; + } + } + + return validSubjectConf; + } + + /** + * Validate a (Bearer) Subject Confirmation + */ + private void validateSubjectConfirmation( + org.opensaml.saml.saml2.core.SubjectConfirmationData subjectConfData, String id, boolean postBinding + ) throws WSSecurityException { + if (subjectConfData == null) { + LOG.fine("Subject Confirmation Data of a Bearer Subject Confirmation is null"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // Recipient must match assertion consumer URL + String recipient = subjectConfData.getRecipient(); + if (recipient == null || !recipient.equals(assertionConsumerURL)) { + LOG.fine("Recipient " + recipient + " does not match assertion consumer URL " + + assertionConsumerURL); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // We must have a NotOnOrAfter timestamp + if (subjectConfData.getNotOnOrAfter() == null + || subjectConfData.getNotOnOrAfter().isBeforeNow()) { + LOG.fine("Subject Conf Data does not contain NotOnOrAfter or it has expired"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // Need to keep bearer assertion IDs based on NotOnOrAfter to detect replay attacks + if (postBinding && replayCache != null) { + if (replayCache.getId(id) == null) { + Date expires = subjectConfData.getNotOnOrAfter().toDate(); + Date currentTime = new Date(); + long ttl = expires.getTime() - currentTime.getTime(); + replayCache.putId(id, ttl / 1000L); + } else { + LOG.fine("Replay attack with token id: " + id); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + } + + // Check address + if (subjectConfData.getAddress() != null && clientAddress != null + && !subjectConfData.getAddress().equals(clientAddress)) { + LOG.fine("Subject Conf Data address " + subjectConfData.getAddress() + " does match" + + " client address " + clientAddress); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // It must not contain a NotBefore timestamp + if (subjectConfData.getNotBefore() != null) { + LOG.fine("The Subject Conf Data must not contain a NotBefore timestamp"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + // InResponseTo must match the AuthnRequest request Id + if (requestId != null && !requestId.equals(subjectConfData.getInResponseTo())) { + LOG.fine("The InResponseTo String does match the original request id " + requestId); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } else if (requestId == null && subjectConfData.getInResponseTo() != null) { + LOG.fine("No InResponseTo String is allowed for the unsolicted case"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + + } + + private void validateAudienceRestrictionCondition( + org.opensaml.saml.saml2.core.Conditions conditions + ) throws WSSecurityException { + if (conditions == null) { + LOG.fine("Conditions are null"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + List<AudienceRestriction> audienceRestrs = conditions.getAudienceRestrictions(); + if (!matchSaml2AudienceRestriction(spIdentifier, audienceRestrs)) { + LOG.fine("Assertion does not contain unique subject provider identifier " + + spIdentifier + " in the audience restriction conditions"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + } + + + private boolean matchSaml2AudienceRestriction( + String appliesTo, List<AudienceRestriction> audienceRestrictions + ) { + boolean oneMatchFound = false; + if (audienceRestrictions != null && !audienceRestrictions.isEmpty()) { + for (AudienceRestriction audienceRestriction : audienceRestrictions) { + if (audienceRestriction.getAudiences() != null) { + boolean matchFound = false; + for (org.opensaml.saml.saml2.core.Audience audience : audienceRestriction.getAudiences()) { + if (appliesTo.equals(audience.getAudienceURI())) { + matchFound = true; + oneMatchFound = true; + break; + } + } + if (!matchFound) { + return false; + } + } + } + } + + return oneMatchFound; + } + + public String getIssuerIDP() { + return issuerIDP; + } + + public void setIssuerIDP(String issuerIDP) { + this.issuerIDP = issuerIDP; + } + + public String getAssertionConsumerURL() { + return assertionConsumerURL; + } + + public void setAssertionConsumerURL(String assertionConsumerURL) { + this.assertionConsumerURL = assertionConsumerURL; + } + + public String getClientAddress() { + return clientAddress; + } + + public void setClientAddress(String clientAddress) { + this.clientAddress = clientAddress; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getSpIdentifier() { + return spIdentifier; + } + + public void setSpIdentifier(String spIdentifier) { + this.spIdentifier = spIdentifier; + } + + public void setReplayCache(TokenReplayCache<String> replayCache) { + this.replayCache = replayCache; + } + + public boolean isEnforceResponseSigned() { + return enforceResponseSigned; + } + + /** + * Enforce whether a SAML Response must be signed. + */ + public void setEnforceResponseSigned(boolean enforceResponseSigned) { + this.enforceResponseSigned = enforceResponseSigned; + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java index 28a1ef0..a9b63ed 100644 --- a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java @@ -117,7 +117,6 @@ import org.opensaml.xmlsec.keyinfo.KeyInfoGenerator; import org.opensaml.xmlsec.keyinfo.impl.X509KeyInfoGeneratorFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Component; import org.apache.syncope.core.provisioning.api.data.ItemTransformer; import org.apache.syncope.core.provisioning.api.serialization.POJOHelper; @@ -176,6 +175,10 @@ public class SAML2SPLogic extends AbstractSAML2Logic<AbstractBaseBean> { @Resource(name = "syncopeJWTSSOProviderDelegate") private JwsSignatureVerifier jwsSignatureVerifier; + private String getAssertionConsumerURL(final String spEntityID, final String urlContext) { + return spEntityID + urlContext + "/assertion-consumer"; + } + @PreAuthorize("hasRole('" + StandardEntitlement.ANONYMOUS + "')") public void getMetadata(final String spEntityID, final String urlContext, final OutputStream os) { check(); @@ -209,7 +212,7 @@ public class SAML2SPLogic extends AbstractSAML2Logic<AbstractBaseBean> { AssertionConsumerService assertionConsumerService = new AssertionConsumerServiceBuilder().buildObject(); assertionConsumerService.setIndex(bindingType.ordinal()); assertionConsumerService.setBinding(bindingType.getUri()); - assertionConsumerService.setLocation(spEntityID + urlContext + "/assertion-consumer"); + assertionConsumerService.setLocation(getAssertionConsumerURL(spEntityID, urlContext)); spSSODescriptor.getAssertionConsumerServices().add(assertionConsumerService); spEntityDescriptor.getRoleDescriptors().add(spSSODescriptor); @@ -450,24 +453,19 @@ public class SAML2SPLogic extends AbstractSAML2Logic<AbstractBaseBean> { throw sce; } - // 3. further checks: - // 3a. the SAML Reponse's InResponseTo - if (!relayState.getJwtClaims().getSubject().equals(samlResponse.getInResponseTo())) { - throw new IllegalArgumentException("Unmatching request ID: " + samlResponse.getInResponseTo()); - } - // 3b. the SAML Response status - if (!StatusCode.SUCCESS.equals(samlResponse.getStatus().getStatusCode().getValue())) { - throw new BadCredentialsException("The SAML IdP replied with " - + samlResponse.getStatus().getStatusCode().getValue()); - } - - // 4. validate the SAML response and, if needed, decrypt the provided assertion(s) + // 3. validate the SAML response and, if needed, decrypt the provided assertion(s) SAML2IdPEntity idp = getIdP(samlResponse.getIssuer().getValue()); if (idp.getConnObjectKeyItem() == null) { throw new IllegalArgumentException("No mapping provided for SAML 2.0 IdP '" + idp.getId() + "'"); } try { - saml2rw.validate(samlResponse, idp.getTrustStore()); + saml2rw.validate( + samlResponse, + idp, + getAssertionConsumerURL(response.getSpEntityID(), response.getUrlContext()), + response.getClientAddress(), + relayState.getJwtClaims().getSubject(), + response.getSpEntityID()); } catch (Exception e) { LOG.error("While validating AuthnResponse", e); SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); @@ -475,7 +473,7 @@ public class SAML2SPLogic extends AbstractSAML2Logic<AbstractBaseBean> { throw sce; } - // 5. prepare the result: find matching user (if any) and return the received attributes + // 4. prepare the result: find matching user (if any) and return the received attributes SAML2LoginResponseTO responseTO = new SAML2LoginResponseTO(); responseTO.setIdp(idp.getId()); responseTO.setSloSupported(idp.getSLOLocation(idp.getBindingType()) != null); @@ -541,7 +539,8 @@ public class SAML2SPLogic extends AbstractSAML2Logic<AbstractBaseBean> { responseTO.setUsername(userDAO.find(matchingUsers.get(0)).getUsername()); responseTO.setNameID(nameID.getValue()); - // 6. generate JWT for further access + + // 5. generate JWT for further access Map<String, Object> claims = new HashMap<>(); claims.put(JWT_CLAIM_IDP_ENTITYID, idp.getId()); claims.put(JWT_CLAIM_NAMEID_FORMAT, nameID.getFormat()); http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java index 0698b38..17386ea 100644 --- a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java @@ -28,7 +28,6 @@ import java.io.Writer; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; -import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.SignatureException; @@ -44,8 +43,10 @@ import javax.xml.transform.stream.StreamResult; import org.apache.commons.codec.binary.Base64; import org.apache.cxf.rs.security.saml.DeflateEncoderDecoder; import org.apache.cxf.rs.security.saml.sso.SAMLProtocolResponseValidator; +import org.apache.cxf.rs.security.saml.sso.SAMLSSOResponseValidator; import org.apache.cxf.staxutils.StaxUtils; import org.apache.syncope.common.lib.SSOConstants; +import org.apache.syncope.common.lib.types.SAML2BindingType; import org.apache.syncope.core.logic.init.SAML2SPLoader; import org.apache.wss4j.common.crypto.Merlin; import org.apache.wss4j.common.ext.WSSecurityException; @@ -91,6 +92,8 @@ public class SAML2ReaderWriter { private SAMLProtocolResponseValidator protocolValidator; + private SAMLSSOResponseValidator ssoResponseValidator; + private SAMLSPCallbackHandler callbackHandler; public void init() { @@ -109,6 +112,8 @@ public class SAML2ReaderWriter { protocolValidator = new SAMLProtocolResponseValidator(); protocolValidator.setKeyInfoMustBeAvailable(true); + ssoResponseValidator = new SAMLSSOResponseValidator(); + callbackHandler = new SAMLSPCallbackHandler(loader.getKeyPass()); } @@ -205,14 +210,28 @@ public class SAML2ReaderWriter { return Base64.encodeBase64String(deflatedBytes); } - public void validate(final Response samlResponse, final KeyStore idpTrustStore) throws WSSecurityException { + public void validate( + final Response samlResponse, + final SAML2IdPEntity idp, + final String assertionConsumerURL, + final String clientAddress, + final String requestId, + final String spEntityID) + throws WSSecurityException { + // validate the SAML response and, if needed, decrypt the provided assertion(s) Merlin crypto = new Merlin(); crypto.setKeyStore(loader.getKeyStore()); - crypto.setTrustStore(idpTrustStore); + crypto.setTrustStore(idp.getTrustStore()); protocolValidator.validateSamlResponse(samlResponse, crypto, callbackHandler); + ssoResponseValidator.setAssertionConsumerURL(assertionConsumerURL); + ssoResponseValidator.setIssuerIDP(idp.getId()); + ssoResponseValidator.setRequestId(requestId); + ssoResponseValidator.setSpIdentifier(spEntityID); + ssoResponseValidator.validateSamlResponse(samlResponse, idp.getBindingType() == SAML2BindingType.POST); + if (LOG.isDebugEnabled()) { try { StringWriter writer = new StringWriter(); http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/fit/core-reference/pom.xml ---------------------------------------------------------------------- diff --git a/fit/core-reference/pom.xml b/fit/core-reference/pom.xml index ac7ddcf..de491a3 100644 --- a/fit/core-reference/pom.xml +++ b/fit/core-reference/pom.xml @@ -363,7 +363,6 @@ under the License. <filtering>true</filtering> <excludes> <exclude>keystore</exclude> - <exclude>**/*.jks</exclude> </excludes> </testResource> <testResource> @@ -371,7 +370,6 @@ under the License. <filtering>false</filtering> <includes> <include>keystore</include> - <include>**/*.jks</include> </includes> </testResource> <testResource> http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/fit/core-reference/src/test/java/org/apache/syncope/fit/core/SAML2ITCase.java ---------------------------------------------------------------------- diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/SAML2ITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/SAML2ITCase.java index a61b00c..e3cb7e0 100644 --- a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/SAML2ITCase.java +++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/SAML2ITCase.java @@ -198,8 +198,7 @@ public class SAML2ITCase extends AbstractITCase { // Get a valid login request for the Fediz realm SAML2SPService saml2Service = anonymous.getService(SAML2SPService.class); - SAML2RequestTO loginRequest = - saml2Service.createLoginRequest(ADDRESS, "urn:org:apache:cxf:fediz:idp:realm-A"); + SAML2RequestTO loginRequest = saml2Service.createLoginRequest(ADDRESS, "urn:org:apache:cxf:fediz:idp:realm-A"); assertNotNull(loginRequest); assertEquals("https://localhost:8443/fediz-idp/saml/up", loginRequest.getIdpServiceAddress()); @@ -209,11 +208,13 @@ public class SAML2ITCase extends AbstractITCase { // Check a null relaystate SAML2ReceivedResponseTO response = new SAML2ReceivedResponseTO(); + response.setSpEntityID("http://recipient.apache.org/"); + response.setUrlContext("saml2sp"); try { saml2Service.validateLoginResponse(response); fail("Failure expected on no Relay State"); - } catch (SyncopeClientException ex) { - assertTrue(ex.getMessage().contains("No Relay State was provided")); + } catch (SyncopeClientException e) { + assertTrue(e.getMessage().contains("No Relay State was provided")); } // Check a null Response @@ -221,16 +222,17 @@ public class SAML2ITCase extends AbstractITCase { try { saml2Service.validateLoginResponse(response); fail("Failure expected on no SAML Response"); - } catch (SyncopeClientException ex) { - assertTrue(ex.getMessage().contains("No SAML Response was provided")); + } catch (SyncopeClientException e) { + assertTrue(e.getMessage().contains("No SAML Response was provided")); } // Create a SAML Response using WSS4J - Document doc = DOMUtils.newDocument(); JwsJwtCompactConsumer relayState = new JwsJwtCompactConsumer(response.getRelayState()); String inResponseTo = relayState.getJwtClaims().getSubject(); org.opensaml.saml.saml2.core.Response samlResponse = createResponse(inResponseTo); + + Document doc = DOMUtils.newDocument(); Element responseElement = OpenSAMLUtil.toDom(samlResponse, doc); String responseStr = DOM2Writer.nodeToString(responseElement); @@ -242,26 +244,27 @@ public class SAML2ITCase extends AbstractITCase { } @Test - @org.junit.Ignore - public void testUnsignedAssertionInLoginResponse() throws Exception { + public void unsignedAssertionInLoginResponse() throws Exception { Assume.assumeTrue(SAML2SPDetector.isSAML2SPAvailable()); // Get a valid login request for the Fediz realm SAML2SPService saml2Service = anonymous.getService(SAML2SPService.class); - SAML2RequestTO loginRequest = - saml2Service.createLoginRequest(ADDRESS, "urn:org:apache:cxf:fediz:idp:realm-A"); + SAML2RequestTO loginRequest = saml2Service.createLoginRequest(ADDRESS, "urn:org:apache:cxf:fediz:idp:realm-A"); assertNotNull(loginRequest); SAML2ReceivedResponseTO response = new SAML2ReceivedResponseTO(); + response.setSpEntityID("http://recipient.apache.org/"); + response.setUrlContext("saml2sp"); response.setRelayState(loginRequest.getRelayState()); // Create a SAML Response using WSS4J - Document doc = DOMUtils.newDocument(); JwsJwtCompactConsumer relayState = new JwsJwtCompactConsumer(response.getRelayState()); String inResponseTo = relayState.getJwtClaims().getSubject(); org.opensaml.saml.saml2.core.Response samlResponse = createResponse(inResponseTo, false, SAML2Constants.CONF_SENDER_VOUCHES); + + Document doc = DOMUtils.newDocument(); Element responseElement = OpenSAMLUtil.toDom(samlResponse, doc); String responseStr = DOM2Writer.nodeToString(responseElement); @@ -270,35 +273,35 @@ public class SAML2ITCase extends AbstractITCase { try { saml2Service.validateLoginResponse(response); fail("Failure expected on an unsigned Assertion"); - } catch (SyncopeClientException ex) { - // expected + } catch (SyncopeClientException e) { + assertNotNull(e); } } @Test - @org.junit.Ignore - public void testLoginResponseWrappingAttack() throws Exception { + public void loginResponseWrappingAttack() throws Exception { Assume.assumeTrue(SAML2SPDetector.isSAML2SPAvailable()); // Get a valid login request for the Fediz realm SAML2SPService saml2Service = anonymous.getService(SAML2SPService.class); - SAML2RequestTO loginRequest = - saml2Service.createLoginRequest(ADDRESS, "urn:org:apache:cxf:fediz:idp:realm-A"); + SAML2RequestTO loginRequest = saml2Service.createLoginRequest(ADDRESS, "urn:org:apache:cxf:fediz:idp:realm-A"); assertNotNull(loginRequest); SAML2ReceivedResponseTO response = new SAML2ReceivedResponseTO(); + response.setSpEntityID("http://recipient.apache.org/"); + response.setUrlContext("saml2sp"); response.setRelayState(loginRequest.getRelayState()); // Create a SAML Response using WSS4J - Document doc = DOMUtils.newDocument(); JwsJwtCompactConsumer relayState = new JwsJwtCompactConsumer(response.getRelayState()); String inResponseTo = relayState.getJwtClaims().getSubject(); org.opensaml.saml.saml2.core.Response samlResponse = createResponse(inResponseTo); - Element responseElement = OpenSAMLUtil.toDom(samlResponse, doc); - doc.appendChild(responseElement); + Document doc = DOMUtils.newDocument(); + Element responseElement = OpenSAMLUtil.toDom(samlResponse, doc); assertNotNull(responseElement); + doc.appendChild(responseElement); // Get Assertion Element Element assertionElement = @@ -329,9 +332,12 @@ public class SAML2ITCase extends AbstractITCase { // Validate the SAML Response response.setSamlResponse(Base64.encodeBase64String(responseStr.getBytes())); - SAML2LoginResponseTO loginResponse = saml2Service.validateLoginResponse(response); - assertNotNull(loginResponse.getAccessToken()); - assertEquals("puccini", loginResponse.getNameID()); + try { + saml2Service.validateLoginResponse(response); + fail("Failure expected on an unsigned Assertion"); + } catch (SyncopeClientException e) { + assertNotNull(e); + } } private org.opensaml.saml.saml2.core.Response createResponse(final String inResponseTo) throws Exception { @@ -343,9 +349,8 @@ public class SAML2ITCase extends AbstractITCase { Status status = SAML2PResponseComponentBuilder.createStatus( SAMLProtocolResponseValidator.SAML2_STATUSCODE_SUCCESS, null); - org.opensaml.saml.saml2.core.Response response = - SAML2PResponseComponentBuilder.createSAMLResponse( - inResponseTo, "urn:org:apache:cxf:fediz:idp:realm-A", status); + org.opensaml.saml.saml2.core.Response response = SAML2PResponseComponentBuilder.createSAMLResponse( + inResponseTo, "urn:org:apache:cxf:fediz:idp:realm-A", status); response.setDestination("http://recipient.apache.org"); // Create an AuthenticationAssertion @@ -356,9 +361,9 @@ public class SAML2ITCase extends AbstractITCase { SubjectConfirmationDataBean subjectConfirmationData = new SubjectConfirmationDataBean(); subjectConfirmationData.setAddress("http://apache.org"); - subjectConfirmationData.setInResponseTo("12345"); + subjectConfirmationData.setInResponseTo(inResponseTo); subjectConfirmationData.setNotAfter(new DateTime().plusMinutes(5)); - subjectConfirmationData.setRecipient("http://recipient.apache.org"); + subjectConfirmationData.setRecipient("http://recipient.apache.org/saml2sp/assertion-consumer"); callbackHandler.setSubjectConfirmationData(subjectConfirmationData); ConditionsBean conditions = new ConditionsBean(); @@ -366,7 +371,7 @@ public class SAML2ITCase extends AbstractITCase { conditions.setNotAfter(new DateTime().plusMinutes(5)); AudienceRestrictionBean audienceRestriction = new AudienceRestrictionBean(); - audienceRestriction.setAudienceURIs(Collections.singletonList("http://service.apache.org")); + audienceRestriction.setAudienceURIs(Collections.singletonList("http://recipient.apache.org/")); conditions.setAudienceRestrictions(Collections.singletonList(audienceRestriction)); callbackHandler.setConditions(conditions); @@ -377,12 +382,12 @@ public class SAML2ITCase extends AbstractITCase { if (signAssertion) { Crypto issuerCrypto = new Merlin(); KeyStore keyStore = KeyStore.getInstance("JKS"); - ClassLoader loader = Loader.getClassLoader(SAML2ITCase.class); - InputStream input = Merlin.loadInputStream(loader, "stsrealm_a.jks"); - keyStore.load(input, "storepass".toCharArray()); + ClassLoader loader = Loader.getClassLoader(getClass()); + InputStream input = Merlin.loadInputStream(loader, "keystore"); + keyStore.load(input, "changeit".toCharArray()); ((Merlin) issuerCrypto).setKeyStore(keyStore); - assertion.signAssertion("realma", "realma", issuerCrypto, false); + assertion.signAssertion("sp", "changeit", issuerCrypto, false); } response.getAssertions().add(assertion.getSaml2()); http://git-wip-us.apache.org/repos/asf/syncope/blob/4dcc0db4/fit/core-reference/src/test/resources/stsrealm_a.jks ---------------------------------------------------------------------- diff --git a/fit/core-reference/src/test/resources/stsrealm_a.jks b/fit/core-reference/src/test/resources/stsrealm_a.jks deleted file mode 100644 index fde2928..0000000 Binary files a/fit/core-reference/src/test/resources/stsrealm_a.jks and /dev/null differ