package com.onelogin.saml2.authn;
import java.io.IOException;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPathExpressionException;
import org.joda.time.DateTime;
import org.joda.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import com.onelogin.saml2.http.HttpRequest;
import com.onelogin.saml2.model.SamlResponseStatus;
import com.onelogin.saml2.model.SubjectConfirmationIssue;
import com.onelogin.saml2.settings.Saml2Settings;
import com.onelogin.saml2.util.Constants;
import com.onelogin.saml2.util.SchemaFactory;
import com.onelogin.saml2.util.Util;
import com.onelogin.saml2.exception.SettingsException;
import com.onelogin.saml2.exception.ValidationError;
/**
* SamlResponse class of OneLogin's Java Toolkit.
*
* A class that implements SAML 2 Authentication Response parser/validator
*/
public class SamlResponse {
/**
* Private property to construct a logger for this class.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(SamlResponse.class);
/**
* Settings data.
*/
private final Saml2Settings settings;
/**
* The decoded, unprocessed XML response provided to the constructor.
*/
private String samlResponseString;
/**
* A DOMDocument object loaded from the SAML Response.
*/
private Document samlResponseDocument;
/**
* A DOMDocument object loaded from the SAML Response (Decrypted).
*/
private Document decryptedDocument;
/**
* URL of the current host + current view
*/
private String currentUrl;
/**
* Mark if the response contains an encrypted assertion.
*/
private Boolean encrypted = false;
/**
* After validation, if it fails this property has the cause of the problem
*/
private String error;
/**
* Constructor to have a Response object full builded and ready to validate
* the saml response
*
* @param settings
* Saml2Settings object. Setting data
* @param request
* the HttpRequest object to be processed (Contains GET and POST parameters, request URL, ...).
*
* @throws ValidationError
* @throws SettingsException
* @throws IOException
* @throws SAXException
* @throws ParserConfigurationException
* @throws XPathExpressionException
*
*/
public SamlResponse(Saml2Settings settings, HttpRequest request) throws XPathExpressionException, ParserConfigurationException, SAXException, IOException, SettingsException, ValidationError {
this.settings = settings;
if (request != null) {
currentUrl = request.getRequestURL();
loadXmlFromBase64(request.getParameter("SAMLResponse"));
}
}
/**
* Load a XML base64encoded SAMLResponse
*
* @param responseStr
* Saml2Settings object. Setting data
*
* @throws ParserConfigurationException
* @throws SettingsException
* @throws IOException
* @throws SAXException
* @throws XPathExpressionException
* @throws ValidationError
*/
public void loadXmlFromBase64(String responseStr) throws ParserConfigurationException, XPathExpressionException, SAXException, IOException, SettingsException, ValidationError {
samlResponseString = new String(Util.base64decoder(responseStr), "UTF-8");
samlResponseDocument = Util.loadXML(samlResponseString);
if (samlResponseDocument == null) {
throw new ValidationError("SAML Response could not be processed", ValidationError.INVALID_XML_FORMAT);
}
NodeList encryptedAssertionNodes = samlResponseDocument.getElementsByTagNameNS(Constants.NS_SAML,"EncryptedAssertion");
if (encryptedAssertionNodes.getLength() != 0) {
decryptedDocument = Util.copyDocument(samlResponseDocument);
encrypted = true;
decryptedDocument = this.decryptAssertion(decryptedDocument);
}
}
/**
* Determines if the SAML Response is valid using the certificate.
*
* @param requestId The ID of the AuthNRequest sent by this SP to the IdP
*
* @return if the response is valid or not
*/
public boolean isValid(String requestId) {
error = null;
try {
if (samlResponseDocument == null) {
throw new Exception("SAML Response is not loaded");
}
if (this.currentUrl == null || this.currentUrl.isEmpty()) {
throw new Exception("The URL of the current host was not established");
}
Element rootElement = samlResponseDocument.getDocumentElement();
rootElement.normalize();
// Check SAML version
if (!rootElement.getAttribute("Version").equals("2.0")) {
throw new ValidationError("Unsupported SAML Version.", ValidationError.UNSUPPORTED_SAML_VERSION);
}
// Check ID in the response
if (!rootElement.hasAttribute("ID")) {
throw new ValidationError("Missing ID attribute on SAML Response.", ValidationError.MISSING_ID);
}
this.checkStatus();
if (!this.validateNumAssertions()) {
throw new ValidationError("SAML Response must contain 1 Assertion.", ValidationError.WRONG_NUMBER_OF_ASSERTIONS);
}
ArrayList<String> signedElements = processSignedElements();
String responseTag = "{" + Constants.NS_SAMLP + "}Response";
String assertionTag = "{" + Constants.NS_SAML + "}Assertion";
final boolean hasSignedResponse = signedElements.contains(responseTag);
final boolean hasSignedAssertion = signedElements.contains(assertionTag);
if (settings.isStrict()) {
if (settings.getWantXMLValidation()) {
if (!Util.validateXML(samlResponseDocument, SchemaFactory.SAML_SCHEMA_PROTOCOL_2_0)) {
throw new ValidationError("Invalid SAML Response. Not match the saml-schema-protocol-2.0.xsd", ValidationError.INVALID_XML_FORMAT);
}
// If encrypted, check also the decrypted document
if (encrypted) {
if (!Util.validateXML(decryptedDocument, SchemaFactory.SAML_SCHEMA_PROTOCOL_2_0)) {
throw new ValidationError("Invalid decrypted SAML Response. Not match the saml-schema-protocol-2.0.xsd", ValidationError.INVALID_XML_FORMAT);
}
}
}
String responseInResponseTo = rootElement.hasAttribute("InResponseTo") ? rootElement.getAttribute("InResponseTo") : null;
if (requestId == null && responseInResponseTo != null && settings.isRejectUnsolicitedResponsesWithInResponseTo()) {
throw new ValidationError("The Response has an InResponseTo attribute: " + responseInResponseTo +
" while no InResponseTo was expected", ValidationError.WRONG_INRESPONSETO);
}
// Check if the InResponseTo of the Response matches the ID of the AuthNRequest (requestId) if provided
if (requestId != null && !Objects.equals(responseInResponseTo, requestId)) {
throw new ValidationError("The InResponseTo of the Response: " + responseInResponseTo
+ ", does not match the ID of the AuthNRequest sent by the SP: " + requestId, ValidationError.WRONG_INRESPONSETO);
}
if (!this.encrypted && settings.getWantAssertionsEncrypted()) {
throw new ValidationError("The assertion of the Response is not encrypted and the SP requires it", ValidationError.NO_ENCRYPTED_ASSERTION);
}
if (settings.getWantNameIdEncrypted()) {
NodeList encryptedNameIdNodes = this.queryAssertion("/saml:Subject/saml:EncryptedID/xenc:EncryptedData");
if (encryptedNameIdNodes.getLength() == 0) {
throw new ValidationError("The NameID of the Response is not encrypted and the SP requires it", ValidationError.NO_ENCRYPTED_NAMEID);
}
}
// Validate Conditions element exists
if (!this.checkOneCondition()) {
throw new ValidationError("The Assertion must include a Conditions element", ValidationError.MISSING_CONDITIONS);
}
// Validate Assertion timestamps
if (!this.validateTimestamps()) {
throw new Exception("Timing issues (please check your clock settings)");
}
// Validate AuthnStatement element exists and is unique
if (!this.checkOneAuthnStatement()) {
throw new ValidationError("The Assertion must include an AuthnStatement element", ValidationError.WRONG_NUMBER_OF_AUTHSTATEMENTS);
}
// EncryptedAttributes are not supported
NodeList encryptedAttributeNodes = this.queryAssertion("/saml:AttributeStatement/saml:EncryptedAttribute");
if (encryptedAttributeNodes.getLength() > 0) {
throw new ValidationError("There is an EncryptedAttribute in the Response and this SP not support them", ValidationError.ENCRYPTED_ATTRIBUTES);
}
// Check destination
if (rootElement.hasAttribute("Destination")) {
String destinationUrl = rootElement.getAttribute("Destination");
if (destinationUrl != null) {
if (destinationUrl.isEmpty()) {
throw new ValidationError("The response has an empty Destination value", ValidationError.EMPTY_DESTINATION);
} else if (!destinationUrl.equals(currentUrl)) {
throw new ValidationError("The response was received at " + currentUrl + " instead of "
+ destinationUrl, ValidationError.WRONG_DESTINATION);
}
}
}
// Check Audience
List<String> validAudiences = this.getAudiences();
if (!validAudiences.isEmpty() && !validAudiences.contains(settings.getSpEntityId())) {
throw new ValidationError(settings.getSpEntityId() + " is not a valid audience for this Response", ValidationError.WRONG_AUDIENCE);
}
// Check the issuers
List<String> issuers = this.getIssuers();
for (int i = 0; i < issuers.size(); i++) {
String issuer = issuers.get(i);
if (issuer.isEmpty() || !issuer.equals(settings.getIdpEntityId())) {
throw new ValidationError(
String.format("Invalid issuer in the Assertion/Response. Was '%s', but expected '%s'", issuer, settings.getIdpEntityId()),
ValidationError.WRONG_ISSUER);
}
}
// Check the session Expiration
DateTime sessionExpiration = this.getSessionNotOnOrAfter();
if (sessionExpiration != null) {
sessionExpiration = sessionExpiration.plus(Constants.ALOWED_CLOCK_DRIFT * 1000);
if (sessionExpiration.isEqualNow() || sessionExpiration.isBeforeNow()) {
throw new ValidationError("The attributes have expired, based on the SessionNotOnOrAfter of the AttributeStatement of this Response", ValidationError.SESSION_EXPIRED);
}
}
validateSubjectConfirmation(responseInResponseTo);
if (settings.getWantAssertionsSigned() && !hasSignedAssertion) {
throw new ValidationError("The Assertion of the Response is not signed and the SP requires it", ValidationError.NO_SIGNED_ASSERTION);
}
if (settings.getWantMessagesSigned() && !hasSignedResponse) {
throw new ValidationError("The Message of the Response is not signed and the SP requires it", ValidationError.NO_SIGNED_MESSAGE);
}
}
if (signedElements.isEmpty() || (!hasSignedAssertion && !hasSignedResponse)) {
throw new ValidationError("No Signature found. SAML Response rejected", ValidationError.NO_SIGNATURE_FOUND);
} else {
X509Certificate cert = settings.getIdpx509cert();
String fingerprint = settings.getIdpCertFingerprint();
String alg = settings.getIdpCertFingerprintAlgorithm();
if (hasSignedResponse && !Util.validateSign(samlResponseDocument, cert, fingerprint, alg, Util.RESPONSE_SIGNATURE_XPATH)) {
throw new ValidationError("Signature validation failed. SAML Response rejected", ValidationError.INVALID_SIGNATURE);
}
final Document documentToCheckAssertion = encrypted ? decryptedDocument : samlResponseDocument;
if (hasSignedAssertion && !Util.validateSign(documentToCheckAssertion, cert, fingerprint, alg, Util.ASSERTION_SIGNATURE_XPATH)) {
throw new ValidationError("Signature validation failed. SAML Response rejected", ValidationError.INVALID_SIGNATURE);
}
}
LOGGER.debug("SAMLResponse validated --> " + samlResponseString);
return true;
} catch (Exception e) {
error = e.getMessage();
LOGGER.debug("SAMLResponse invalid --> " + samlResponseString);
LOGGER.error(error);
return false;
}
}
/**
* Check SubjectConfirmation, at least one SubjectConfirmation must be valid
*
* @param responseInResponseTo
* The InResponseTo value of the SAML Response
*
* @throws XPathExpressionException
* @throws ValidationError
*/
private void validateSubjectConfirmation(String responseInResponseTo) throws XPathExpressionException, ValidationError {
final List<SubjectConfirmationIssue> validationIssues = new ArrayList<>();
boolean validSubjectConfirmation = false;
NodeList subjectConfirmationNodes = this.queryAssertion("/saml:Subject/saml:SubjectConfirmation");
for (int i = 0; i < subjectConfirmationNodes.getLength(); i++) {
Node scn = subjectConfirmationNodes.item(i);
Node method = scn.getAttributes().getNamedItem("Method");
if (method != null && !method.getNodeValue().equals(Constants.CM_BEARER)) {
continue;
}
NodeList subjectConfirmationDataNodes = scn.getChildNodes();
for (int c = 0; c < subjectConfirmationDataNodes.getLength(); c++) {
if (subjectConfirmationDataNodes.item(c).getLocalName() != null && subjectConfirmationDataNodes.item(c).getLocalName().equals("SubjectConfirmationData")) {
Node recipient = subjectConfirmationDataNodes.item(c).getAttributes().getNamedItem("Recipient");
if (recipient == null) {
validationIssues.add(new SubjectConfirmationIssue(i, "SubjectConfirmationData doesn't contain a Recipient"));
continue;
}
if (!recipient.getNodeValue().equals(currentUrl)) {
validationIssues.add(new SubjectConfirmationIssue(i, "SubjectConfirmationData doesn't match a valid Recipient"));
continue;
}
Node inResponseTo = subjectConfirmationDataNodes.item(c).getAttributes().getNamedItem("InResponseTo");
if (inResponseTo == null && responseInResponseTo != null ||
inResponseTo != null && !inResponseTo.getNodeValue().equals(responseInResponseTo)) {
validationIssues.add(new SubjectConfirmationIssue(i, "SubjectConfirmationData has an invalid InResponseTo value"));;
continue;
}
Node notOnOrAfter = subjectConfirmationDataNodes.item(c).getAttributes().getNamedItem("NotOnOrAfter");
if (notOnOrAfter == null) {
validationIssues.add(new SubjectConfirmationIssue(i, "SubjectConfirmationData doesn't contain a NotOnOrAfter attribute"));
continue;
}
DateTime noa = Util.parseDateTime(notOnOrAfter.getNodeValue());
noa = noa.plus(Constants.ALOWED_CLOCK_DRIFT * 1000);
if (noa.isEqualNow() || noa.isBeforeNow()) {
validationIssues.add(new SubjectConfirmationIssue(i, "SubjectConfirmationData is no longer valid"));
continue;
}
Node notBefore = subjectConfirmationDataNodes.item(c).getAttributes().getNamedItem("NotBefore");
if (notBefore != null) {
DateTime nb = Util.parseDateTime(notBefore.getNodeValue());
nb = nb.minus(Constants.ALOWED_CLOCK_DRIFT * 1000);
if (nb.isAfterNow()) {
validationIssues.add(new SubjectConfirmationIssue(i, "SubjectConfirmationData is not yet valid"));
continue;
}
}
validSubjectConfirmation = true;
}
}
}
if (!validSubjectConfirmation) {
throw new ValidationError(SubjectConfirmationIssue.prettyPrintIssues(validationIssues), ValidationError.WRONG_SUBJECTCONFIRMATION);
}
}
/**
* Determines if the SAML Response is valid using the certificate.
*
* @return if the response is valid or not
*/
public boolean isValid() {
return isValid(null);
}
/**
* Gets the NameID provided from the SAML Response Document.
*
* @return the Name ID Data (Value, Format, NameQualifier, SPNameQualifier)
*
* @throws Exception
*
*/
public HashMap<String,String> getNameIdData() throws Exception {
HashMap<String,String> nameIdData = new HashMap<String, String>();
NodeList encryptedIDNodes = this.queryAssertion("/saml:Subject/saml:EncryptedID");
NodeList nameIdNodes;
Element nameIdElem;
if (encryptedIDNodes.getLength() == 1) {
NodeList encryptedDataNodes = this.queryAssertion("/saml:Subject/saml:EncryptedID/xenc:EncryptedData");
if (encryptedDataNodes.getLength() == 1) {
Element encryptedData = (Element) encryptedDataNodes.item(0);
PrivateKey key = settings.getSPkey();
if (key == null) {
throw new SettingsException("Key is required in order to decrypt the NameID", SettingsException.PRIVATE_KEY_NOT_FOUND);
}
Util.decryptElement(encryptedData, key);
}
nameIdNodes = this.queryAssertion("/saml:Subject/saml:EncryptedID/saml:NameID|/saml:Subject/saml:NameID");
if (nameIdNodes == null || nameIdNodes.getLength() == 0) {
throw new Exception("Not able to decrypt the EncryptedID and get a NameID");
}
} else {
nameIdNodes = this.queryAssertion("/saml:Subject/saml:NameID");
}
if (nameIdNodes != null && nameIdNodes.getLength() == 1) {
nameIdElem = (Element) nameIdNodes.item(0);
if (nameIdElem != null) {
String value = nameIdElem.getTextContent();
if (settings.isStrict() && value.isEmpty()) {
throw new ValidationError("An empty NameID value found", ValidationError.EMPTY_NAMEID);
}
nameIdData.put("Value", value);
if (nameIdElem.hasAttribute("Format")) {
nameIdData.put("Format", nameIdElem.getAttribute("Format"));
}
if (nameIdElem.hasAttribute("SPNameQualifier")) {
String spNameQualifier = nameIdElem.getAttribute("SPNameQualifier");
if (settings.isStrict() && !spNameQualifier.equals(settings.getSpEntityId())) {
throw new ValidationError("The SPNameQualifier value mistmatch the SP entityID value.", ValidationError.SP_NAME_QUALIFIER_NAME_MISMATCH);
} else {
nameIdData.put("SPNameQualifier", spNameQualifier);
}
}
if (nameIdElem.hasAttribute("NameQualifier")) {
nameIdData.put("NameQualifier", nameIdElem.getAttribute("NameQualifier"));
}
}
} else {
if (settings.getWantNameId()) {
throw new ValidationError("No name id found in Document.", ValidationError.NO_NAMEID);
}
}
return nameIdData;
}
/**
* Gets the NameID value provided from the SAML Response String.
*
* @return string Name ID Value
*
* @throws Exception
*/
public String getNameId() throws Exception {
HashMap<String,String> nameIdData = getNameIdData();
String nameID = null;
if (!nameIdData.isEmpty()) {
LOGGER.debug("SAMLResponse has NameID --> " + nameIdData.get("Value"));
nameID = nameIdData.get("Value");
}
return nameID;
}
/**
* Gets the NameID Format provided from the SAML Response String.
*
* @return string NameID Format
*
* @throws Exception
*/
public String getNameIdFormat() throws Exception {
HashMap<String,String> nameIdData = getNameIdData();
String nameidFormat = null;
if (!nameIdData.isEmpty() && nameIdData.containsKey("Format")) {
LOGGER.debug("SAMLResponse has NameID Format --> " + nameIdData.get("Format"));
nameidFormat = nameIdData.get("Format");
}
return nameidFormat;
}
/**
* Gets the Attributes from the AttributeStatement element.
*
* @return the attributes of the SAML Assertion
*
* @throws XPathExpressionException
* @throws ValidationError
*
*/
public HashMap<String, List<String>> getAttributes() throws XPathExpressionException, ValidationError {
HashMap<String, List<String>> attributes = new HashMap<String, List<String>>();
NodeList nodes = this.queryAssertion("/saml:AttributeStatement/saml:Attribute");
if (nodes.getLength() != 0) {
for (int i = 0; i < nodes.getLength(); i++) {
NamedNodeMap attrName = nodes.item(i).getAttributes();
String attName = attrName.getNamedItem("Name").getNodeValue();
if (attributes.containsKey(attName)) {
throw new ValidationError("Found an Attribute element with duplicated Name", ValidationError.DUPLICATED_ATTRIBUTE_NAME_FOUND);
}
NodeList childrens = nodes.item(i).getChildNodes();
List<String> attrValues = new ArrayList<String>();
for (int j = 0; j < childrens.getLength(); j++) {
if ("AttributeValue".equals(childrens.item(j).getLocalName())) {
attrValues.add(childrens.item(j).getTextContent());
}
}
attributes.put(attName, attrValues);
}
LOGGER.debug("SAMLResponse has attributes: " + attributes.toString());
} else {
LOGGER.debug("SAMLResponse has no attributes");
}
return attributes;
}
/**
* Checks the Status
*
* @throws ValidationError
* If status is not success
*/
public void checkStatus() throws ValidationError {
SamlResponseStatus responseStatus = getStatus(samlResponseDocument);
if (!responseStatus.is(Constants.STATUS_SUCCESS)) {
String statusExceptionMsg = "The status code of the Response was not Success, was "
+ responseStatus.getStatusCode();
if (responseStatus.getStatusMessage() != null) {
statusExceptionMsg += " -> " + responseStatus.getStatusMessage();
}
throw new ValidationError(statusExceptionMsg, ValidationError.STATUS_CODE_IS_NOT_SUCCESS);
}
}
/**
* Get Status from a Response
*
* @param dom
* The Response as XML
*
* @return array with the code and a message
*
* @throws IllegalArgumentException
* if the response not contain status or if Unexpected XPath error
* @throws ValidationError
*/
public static SamlResponseStatus getStatus(Document dom) throws ValidationError {
try {
String statusExpr = "/samlp:Response/samlp:Status";
NodeList statusEntry = Util.query(dom, statusExpr, null);
if (statusEntry.getLength() != 1) {
throw new ValidationError("Missing Status on response", ValidationError.MISSING_STATUS);
}
NodeList codeEntry;
codeEntry = Util.query(dom, statusExpr + "/samlp:StatusCode", (Element) statusEntry.item(0));
if (codeEntry.getLength() != 1) {
throw new ValidationError("Missing Status Code on response", ValidationError.MISSING_STATUS_CODE);
}
String stausCode = codeEntry.item(0).getAttributes().getNamedItem("Value").getNodeValue();
SamlResponseStatus status = new SamlResponseStatus(stausCode);
NodeList messageEntry = Util.query(dom, statusExpr + "/samlp:StatusMessage",
(Element) statusEntry.item(0));
if (messageEntry.getLength() == 1) {
status.setStatusMessage(messageEntry.item(0).getTextContent());
}
return status;
} catch (XPathExpressionException e) {
String error = "Unexpected error in getStatus." + e.getMessage();
LOGGER.error(error);
throw new IllegalArgumentException(error);
}
}
/**
* Checks that the samlp:Response/saml:Assertion/saml:Conditions element exists and is unique.
*
* @return true if the Conditions element exists and is unique
*
* @throws XPathExpressionException
*/
public Boolean checkOneCondition() throws XPathExpressionException {
NodeList entries = this.queryAssertion("/saml:Conditions");
if (entries.getLength() == 1) {
return true;
} else {
return false;
}
}
/**
* Checks that the samlp:Response/saml:Assertion/saml:AuthnStatement element exists and is unique.
*
* @return true if the AuthnStatement element exists and is unique
*
* @throws XPathExpressionException
*/
public Boolean checkOneAuthnStatement() throws XPathExpressionException {
NodeList entries = this.queryAssertion("/saml:AuthnStatement");
if (entries.getLength() == 1) {
return true;
} else {
return false;
}
}
/**
* Gets the audiences.
*
* @return the audiences of the response
*
* @throws XPathExpressionException
*/
public List<String> getAudiences() throws XPathExpressionException {
List<String> audiences = new ArrayList<String>();
NodeList entries = this.queryAssertion("/saml:Conditions/saml:AudienceRestriction/saml:Audience");
for (int i = 0; i < entries.getLength(); i++) {
if (entries.item(i) != null) {
String value = entries.item(i).getTextContent();
if (value != null && !value.trim().isEmpty()) {
audiences.add(value.trim());
}
}
}
return audiences;
}
/**
* Gets the Issuers (from Response and Assertion).
*
* @return the issuers of the assertion/response
*
* @throws XPathExpressionException
* @throws ValidationError
*/
public List<String> getIssuers() throws XPathExpressionException, ValidationError {
List<String> issuers = new ArrayList<String>();
String value;
NodeList responseIssuer = Util.query(samlResponseDocument, "/samlp:Response/saml:Issuer");
if (responseIssuer.getLength() > 1) {
if (responseIssuer.getLength() == 1) {
value = responseIssuer.item(0).getTextContent();
if (!issuers.contains(value)) {
issuers.add(value);
}
} else {
throw new ValidationError("Issuer of the Response is multiple.", ValidationError.ISSUER_MULTIPLE_IN_RESPONSE);
}
}
NodeList assertionIssuer = this.queryAssertion("/saml:Issuer");
if (assertionIssuer.getLength() == 1) {
value = assertionIssuer.item(0).getTextContent();
if (!issuers.contains(value)) {
issuers.add(value);
}
} else {
throw new ValidationError("Issuer of the Assertion not found or multiple.", ValidationError.ISSUER_NOT_FOUND_IN_ASSERTION);
}
return issuers;
}
/**
* Gets the SessionNotOnOrAfter from the AuthnStatement. Could be used to
* set the local session expiration
*
* @return the SessionNotOnOrAfter value
*
* @throws XPathExpressionException
*/
public DateTime getSessionNotOnOrAfter() throws XPathExpressionException {
String notOnOrAfter = null;
NodeList entries = this.queryAssertion("/saml:AuthnStatement[@SessionNotOnOrAfter]");
if (entries.getLength() > 0) {
notOnOrAfter = entries.item(0).getAttributes().getNamedItem("SessionNotOnOrAfter").getNodeValue();
return Util.parseDateTime(notOnOrAfter);
}
return null;
}
/**
* Gets the SessionIndex from the AuthnStatement.
* Could be used to be stored in the local session in order
* to be used in a future Logout Request that the SP could
* send to the SP, to set what specific session must be deleted
*
* @return the SessionIndex value
*
* @throws XPathExpressionException
*/
public String getSessionIndex() throws XPathExpressionException {
String sessionIndex = null;
NodeList entries = this.queryAssertion("/saml:AuthnStatement[@SessionIndex]");
if (entries.getLength() > 0) {
sessionIndex = entries.item(0).getAttributes().getNamedItem("SessionIndex").getNodeValue();
}
return sessionIndex;
}
/**
* @return the ID of the Response
*/
public String getId() {
return samlResponseDocument.getDocumentElement().getAttributes().getNamedItem("ID").getNodeValue();
}
/**
* @return the ID of the assertion in the Response
* @throws XPathExpressionException
*
*/
public String getAssertionId() throws XPathExpressionException {
if (!validateNumAssertions()) {
throw new IllegalArgumentException("SAML Response must contain 1 Assertion.");
}
final NodeList assertionNode = queryAssertion("");
return assertionNode.item(0).getAttributes().getNamedItem("ID").getNodeValue();
}
/**
* @return a list of NotOnOrAfter values from SubjectConfirmationData nodes in this Response
* @throws XPathExpressionException
*
*/
public List<Instant> getAssertionNotOnOrAfter() throws XPathExpressionException {
final NodeList notOnOrAfterNodes = queryAssertion("/saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData");
final ArrayList<Instant> notOnOrAfters = new ArrayList<>();
for (int i = 0; i < notOnOrAfterNodes.getLength(); i++) {
final Node notOnOrAfterAttribute = notOnOrAfterNodes.item(i).getAttributes().getNamedItem("NotOnOrAfter");
if (notOnOrAfterAttribute != null) {
notOnOrAfters.add(new Instant(notOnOrAfterAttribute.getNodeValue()));
}}
return notOnOrAfters;
}
/**
* Verifies that the document only contains a single Assertion (encrypted or not).
*
* @return true if the document passes.
*
* @throws IllegalArgumentException
*/
public Boolean validateNumAssertions() throws IllegalArgumentException {
NodeList encryptedAssertionNodes = samlResponseDocument.getElementsByTagNameNS(Constants.NS_SAML, "EncryptedAssertion");
NodeList assertionNodes = samlResponseDocument.getElementsByTagNameNS(Constants.NS_SAML, "Assertion");
Boolean valid = assertionNodes.getLength() + encryptedAssertionNodes.getLength() == 1;
if (encrypted) {
valid = valid && decryptedDocument.getElementsByTagNameNS(Constants.NS_SAML, "Assertion").getLength() == 1;
}
return valid;
}
/**
* Verifies the signature nodes:
* - Checks that are Response or Assertion
* - Check that IDs and reference URI are unique and consistent.
*
* @return array Signed element tags
*
* @throws XPathExpressionException
* @throws ValidationError
*/
public ArrayList<String> processSignedElements() throws XPathExpressionException, ValidationError {
ArrayList<String> signedElements = new ArrayList<String>();
ArrayList<String> verifiedSeis = new ArrayList<String>();
ArrayList<String> verifiedIds = new ArrayList<String>();
NodeList signNodes = query("//ds:Signature", null);
for (int i = 0; i < signNodes.getLength(); i++) {
Node signNode = signNodes.item(i);
String signedElement = "{" + signNode.getParentNode().getNamespaceURI() + "}" + signNode.getParentNode().getLocalName();
String responseTag = "{" + Constants.NS_SAMLP + "}Response";
String assertionTag = "{" + Constants.NS_SAML + "}Assertion";
if (!signedElement.equals(responseTag) && !signedElement.equals(assertionTag)) {
throw new ValidationError("Invalid Signature Element " + signedElement + " SAML Response rejected", ValidationError.WRONG_SIGNED_ELEMENT);
}
// Check that reference URI matches the parent ID and no duplicate References or IDs
Node idNode = signNode.getParentNode().getAttributes().getNamedItem("ID");
if (idNode == null || idNode.getNodeValue() == null || idNode.getNodeValue().isEmpty()) {
throw new ValidationError("Signed Element must contain an ID. SAML Response rejected", ValidationError.ID_NOT_FOUND_IN_SIGNED_ELEMENT);
}
String idValue = idNode.getNodeValue();
if (verifiedIds.contains(idValue)) {
throw new ValidationError("Duplicated ID. SAML Response rejected", ValidationError.DUPLICATED_ID_IN_SIGNED_ELEMENTS);
}
verifiedIds.add(idValue);
NodeList refNodes = Util.query(null, "ds:SignedInfo/ds:Reference", signNode);
if (refNodes.getLength() == 1) {
Node refNode = refNodes.item(0);
Node seiNode = refNode.getAttributes().getNamedItem("URI");
if (seiNode != null && seiNode.getNodeValue() != null && !seiNode.getNodeValue().isEmpty()) {
String sei = seiNode.getNodeValue().substring(1);
if (!sei.equals(idValue)) {
throw new ValidationError("Found an invalid Signed Element. SAML Response rejected", ValidationError.INVALID_SIGNED_ELEMENT);
}
if (verifiedSeis.contains(sei)) {
throw new ValidationError("Duplicated Reference URI. SAML Response rejected", ValidationError.DUPLICATED_REFERENCE_IN_SIGNED_ELEMENTS);
}
verifiedSeis.add(sei);
}
} else {
// Signatures MUST contain a single <ds:Reference> containing a same-document reference to the ID
// attribute value of the root element of the assertion or protocol message being signed
throw new ValidationError("Unexpected number of Reference nodes found for signature. SAML Response rejected.", ValidationError.UNEXPECTED_REFERENCE);
}
signedElements.add(signedElement);
}
if (!signedElements.isEmpty()) {
if (!validateSignedElements(signedElements)) {
throw new ValidationError("Found an unexpected Signature Element. SAML Response rejected", ValidationError.UNEXPECTED_SIGNED_ELEMENTS);
}
}
return signedElements;
}
/**
* Verifies that the document has the expected signed nodes.
*
* @param signedElements
* the elements to be validated
* @return true if is valid
*
* @throws XPathExpressionException
* @throws ValidationError
*
*/
public boolean validateSignedElements(ArrayList<String> signedElements) throws XPathExpressionException, ValidationError {
if (signedElements.size() > 2) {
return false;
}
Map<String, Integer> occurrences = new HashMap<String, Integer>();
for (String e : signedElements) {
if (occurrences.containsKey(e)) {
occurrences.put(e, occurrences.get(e).intValue() + 1);
} else {
occurrences.put(e, 1);
}
}
String responseTag = "{" + Constants.NS_SAMLP + "}Response";
String assertionTag = "{" + Constants.NS_SAML + "}Assertion";
if ((occurrences.containsKey(responseTag) && occurrences.get(responseTag) > 1)
|| (occurrences.containsKey(assertionTag) && occurrences.get(assertionTag) > 1)
|| !occurrences.containsKey(responseTag) && !occurrences.containsKey(assertionTag)) {
return false;
}
// check that the signed elements found here, are the ones that will be verified
// by com.onelogin.saml2.util.Util.validateSign()
if (occurrences.containsKey(responseTag)) {
final NodeList expectedSignatureNode = query(Util.RESPONSE_SIGNATURE_XPATH, null);
if (expectedSignatureNode.getLength() != 1) {
throw new ValidationError("Unexpected number of Response signatures found. SAML Response rejected.", ValidationError.WRONG_NUMBER_OF_SIGNATURES_IN_RESPONSE);
}
}
if (occurrences.containsKey(assertionTag)) {
final NodeList expectedSignatureNode = query(Util.ASSERTION_SIGNATURE_XPATH, null);
if (expectedSignatureNode.getLength() != 1) {
throw new ValidationError("Unexpected number of Assertion signatures found. SAML Response rejected.", ValidationError.WRONG_NUMBER_OF_SIGNATURES_IN_ASSERTION);
}
}
return true;
}
/**
* Verifies that the document is still valid according Conditions Element.
*
* @return true if still valid
*
* @throws ValidationError
*/
public boolean validateTimestamps() throws ValidationError {
NodeList timestampNodes = samlResponseDocument.getElementsByTagNameNS("*", "Conditions");
if (timestampNodes.getLength() != 0) {
for (int i = 0; i < timestampNodes.getLength(); i++) {
NamedNodeMap attrName = timestampNodes.item(i).getAttributes();
Node nbAttribute = attrName.getNamedItem("NotBefore");
Node naAttribute = attrName.getNamedItem("NotOnOrAfter");
// validate NotOnOrAfter
if (naAttribute != null) {
DateTime notOnOrAfterDate = Util.parseDateTime(naAttribute.getNodeValue());
notOnOrAfterDate = notOnOrAfterDate.plus(Constants.ALOWED_CLOCK_DRIFT * 1000);
if (notOnOrAfterDate.isEqualNow() || notOnOrAfterDate.isBeforeNow()) {
throw new ValidationError("Could not validate timestamp: expired. Check system clock.", ValidationError.ASSERTION_EXPIRED);
}
}
// validate NotBefore
if (nbAttribute != null) {
DateTime notBeforeDate = Util.parseDateTime(nbAttribute.getNodeValue());
notBeforeDate = notBeforeDate.minus(Constants.ALOWED_CLOCK_DRIFT * 1000);
if (notBeforeDate.isAfterNow()) {
throw new ValidationError("Could not validate timestamp: not yet valid. Check system clock.", ValidationError.ASSERTION_TOO_EARLY);
}
}
}
}
return true;
}
/**
* Aux method to set the destination url
*
* @param urld
* the url to set as currentUrl
*/
public void setDestinationUrl(String urld) {
currentUrl = urld;
}
/**
* After execute a validation process, if fails this method returns the cause
*
* @return the cause of the validation error
*/
public String getError() {
if (error != null) {
return error;
}
return null;
}
/**
* Extracts a node from the DOMDocument (Assertion).
*
* @param assertionXpath
* Xpath Expression
*
* @return the queried node
* @throws XPathExpressionException
*
*/
private NodeList queryAssertion(String assertionXpath) throws XPathExpressionException {
final String assertionExpr = "/saml:Assertion";
final String signatureExpr = "ds:Signature/ds:SignedInfo/ds:Reference";
String nameQuery;
String signedAssertionQuery = "/samlp:Response" + assertionExpr + "/" + signatureExpr;
NodeList nodeList = query(signedAssertionQuery, null);
if (nodeList.getLength() == 0 ) {
// let see if the whole response signed?
String signedMessageQuery = "/samlp:Response/" + signatureExpr;
nodeList = query(signedMessageQuery, null);
if (nodeList.getLength() == 1) {
Node responseReferenceNode = nodeList.item(0);
String responseId = responseReferenceNode.getAttributes().getNamedItem("URI").getNodeValue();
if (responseId != null && !responseId.isEmpty()) {
responseId = responseId.substring(1);
} else {
responseId = responseReferenceNode.getParentNode().getParentNode().getParentNode().getAttributes().getNamedItem("ID").getNodeValue();
}
nameQuery = "/samlp:Response[@ID='" + responseId + "']";
} else {
// On this case there is no element signed, the query will work but
// the response validation will throw and error.
nameQuery = "/samlp:Response";
}
nameQuery += assertionExpr;
} else { // there is a signed assertion
Node assertionReferenceNode = nodeList.item(0);
String assertionId = assertionReferenceNode.getAttributes().getNamedItem("URI").getNodeValue();
if (assertionId != null && !assertionId.isEmpty()) {
assertionId = assertionId.substring(1);
} else {
assertionId = assertionReferenceNode.getParentNode().getParentNode().getParentNode().getAttributes().getNamedItem("ID").getNodeValue();
}
nameQuery = "/samlp:Response/" + assertionExpr + "[@ID='" + assertionId + "']";
}
nameQuery += assertionXpath;
return query(nameQuery, null);
}
/**
* Extracts nodes that match the query from the DOMDocument (Response Menssage)
*
* @param nameQuery
* Xpath Expression
* @param context
* The context node
*
* @return DOMNodeList The queried nodes
*/
private NodeList query(String nameQuery, Node context) throws XPathExpressionException {
Document doc;
if (encrypted) {
doc = decryptedDocument;
} else {
doc = samlResponseDocument;
}
// LOGGER.debug("Executing query " + nameQuery);
return Util.query(doc, nameQuery, context);
}
/**
* Decrypt assertion.
*
* @param dom
* Encrypted assertion
*
* @return Decrypted Assertion.
*
* @throws XPathExpressionException
* @throws IOException
* @throws SAXException
* @throws ParserConfigurationException
* @throws SettingsException
*/
private Document decryptAssertion(Document dom) throws XPathExpressionException, ParserConfigurationException, SAXException, IOException, SettingsException {
PrivateKey key = settings.getSPkey();
if (key == null) {
throw new SettingsException("No private key available for decrypt, check settings", SettingsException.PRIVATE_KEY_NOT_FOUND);
}
NodeList encryptedDataNodes = Util.query(dom, "/samlp:Response/saml:EncryptedAssertion/xenc:EncryptedData");
Element encryptedData = (Element) encryptedDataNodes.item(0);
Util.decryptElement(encryptedData, key);
// We need to Remove the saml:EncryptedAssertion Node
NodeList AssertionDataNodes = Util.query(dom, "/samlp:Response/saml:EncryptedAssertion/saml:Assertion");
Node assertionNode = AssertionDataNodes.item(0);
assertionNode.getParentNode().getParentNode().replaceChild(assertionNode, assertionNode.getParentNode());
// In order to avoid Signature Validation errors we need to rebuild the dom.
// https://groups.google.com/forum/#!topic/opensaml-users/gpXvwaZ53NA
String xmlStr = Util.convertDocumentToString(dom);
Document doc = Util.convertStringToDocument(xmlStr);
// LOGGER.debug("Decrypted SAMLResponse --> " + xmlStr);
return doc;
}
/**
* @return the SAMLResponse XML, If the Assertion of the SAMLResponse was encrypted,
* returns the XML with the assertion decrypted
*/
public String getSAMLResponseXml() {
String xml;
if (encrypted) {
xml = Util.convertDocumentToString(decryptedDocument);
} else {
xml = samlResponseString;
}
return xml;
}
/**
* @return the SAMLResponse Document, If the Assertion of the SAMLResponse was encrypted,
* returns the Document with the assertion decrypted
*/
protected Document getSAMLResponseDocument() {
Document doc;
if (encrypted) {
doc = decryptedDocument;
} else {
doc = samlResponseDocument;
}
return doc;
}
}