/**
* 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.time.Duration;
import java.time.Instant;
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.
*/
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;
Instant 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 =
Instant.ofEpochMilli(authnStatment.getSessionNotOnOrAfter().toDate().getTime());
}
}
// Fall back to the SubjectConfirmationData NotOnOrAfter if we have no session NotOnOrAfter
if (sessionNotOnOrAfter == null) {
sessionNotOnOrAfter =
Instant.ofEpochMilli(subjectConf.getSubjectConfirmationData()
.getNotOnOrAfter().toDate().getTime());
}
}
}
}
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(Instant.ofEpochMilli(samlResponse.getIssueInstant().toDate().getTime()));
}
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) {
Instant expires = Instant.ofEpochMilli(subjectConfData.getNotOnOrAfter().toDate().getTime());
Instant currentTime = Instant.now();
long ttl = Duration.between(currentTime, expires).getSeconds();
replayCache.putId(id, ttl);
} else {
LOG.fine("Replay attack with token id: " + id);
throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity");
}
}
// Check address
if (subjectConfData.getAddress() != 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;
}
}