<?php
/**
 * This file is part of php-saml.
 *
 * (c) OneLogin Inc
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @package OneLogin
 * @author  OneLogin Inc <saml-info@onelogin.com>
 * @license MIT https://github.com/onelogin/php-saml/blob/master/LICENSE
 * @link    https://github.com/onelogin/php-saml
 */

namespace OneLogin\Saml2;

use RobRichards\XMLSecLibs\XMLSecurityKey;
use RobRichards\XMLSecLibs\XMLSecEnc;

use DOMDocument;
use DOMNodeList;
use DOMXPath;
use Exception;

/**
 * SAML 2 Authentication Response
 */
class Response
{
    /**
     * Settings
     *
     * @var Settings
     */
    protected $_settings;

    /**
     * The decoded, unprocessed XML response provided to the constructor.
     *
     * @var string
     */
    public $response;

    /**
     * A DOMDocument class loaded from the SAML Response.
     *
     * @var DOMDocument
     */
    public $document;

    /**
     * A DOMDocument class loaded from the SAML Response (Decrypted).
     *
     * @var DOMDocument
     */
    public $decryptedDocument;

    /**
     * The response contains an encrypted assertion.
     *
     * @var bool
     */
    public $encrypted = false;

    /**
     * After validation, if it fail this var has the cause of the problem
     *
     * @var Exception|null
     */
    private $_error;

    /**
     * NotOnOrAfter value of a valid SubjectConfirmationData node
     *
     * @var int
     */
    private $_validSCDNotOnOrAfter;

    /**
     * Constructs the SAML Response object.
     *
     * @param Settings $settings Settings.
     * @param string   $response A UUEncoded SAML response from the IdP.
     *
     * @throws Exception
     * @throws ValidationError
     */
    public function __construct(\OneLogin\Saml2\Settings $settings, $response)
    {
        $this->_settings = $settings;

        $baseURL = $this->_settings->getBaseURL();
        if (!empty($baseURL)) {
            Utils::setBaseURL($baseURL);
        }

        $this->response = base64_decode($response);

        $this->document = new DOMDocument();
        $this->document = Utils::loadXML($this->document, $this->response);
        if (!$this->document) {
            throw new ValidationError(
                "SAML Response could not be processed",
                ValidationError::INVALID_XML_FORMAT
            );
        }

        // Quick check for the presence of EncryptedAssertion
        $encryptedAssertionNodes = $this->document->getElementsByTagName('EncryptedAssertion');
        if ($encryptedAssertionNodes->length !== 0) {
            $this->decryptedDocument = clone $this->document;
            $this->encrypted = true;
            $this->decryptedDocument = $this->decryptAssertion($this->decryptedDocument);
        }
    }

    /**
     * Determines if the SAML Response is valid using the certificate.
     *
     * @param string|null $requestId The ID of the AuthNRequest sent by this SP to the IdP
     *
     * @return bool Validate the document
     *
     * @throws Exception
     * @throws ValidationError
     */
    public function isValid($requestId = null)
    {
        $this->_error = null;
        try {
            // Check SAML version
            if ($this->document->documentElement->getAttribute('Version') != '2.0') {
                throw new ValidationError(
                    "Unsupported SAML version",
                    ValidationError::UNSUPPORTED_SAML_VERSION
                );
            }

            if (!$this->document->documentElement->hasAttribute('ID')) {
                throw new ValidationError(
                    "Missing ID attribute on SAML Response",
                    ValidationError::MISSING_ID
                );
            }

            $this->checkStatus();

            $singleAssertion = $this->validateNumAssertions();
            if (!$singleAssertion) {
                throw new ValidationError(
                    "SAML Response must contain 1 assertion",
                    ValidationError::WRONG_NUMBER_OF_ASSERTIONS
                );
            }

            $idpData = $this->_settings->getIdPData();
            $idPEntityId = $idpData['entityId'];
            $spData = $this->_settings->getSPData();
            $spEntityId = $spData['entityId'];

            $signedElements = $this->processSignedElements();

            $responseTag = '{'.Constants::NS_SAMLP.'}Response';
            $assertionTag = '{'.Constants::NS_SAML.'}Assertion';

            $hasSignedResponse = in_array($responseTag, $signedElements);
            $hasSignedAssertion = in_array($assertionTag, $signedElements);

            if ($this->_settings->isStrict()) {
                $security = $this->_settings->getSecurityData();

                if ($security['wantXMLValidation']) {
                    $errorXmlMsg = "Invalid SAML Response. Not match the saml-schema-protocol-2.0.xsd";
                    $res = Utils::validateXML($this->document, 'saml-schema-protocol-2.0.xsd', $this->_settings->isDebugActive(), $this->_settings->getSchemasPath());
                    if (!$res instanceof DOMDocument) {
                        throw new ValidationError(
                            $errorXmlMsg,
                            ValidationError::INVALID_XML_FORMAT
                        );
                    }

                    // If encrypted, check also the decrypted document
                    if ($this->encrypted) {
                        $res = Utils::validateXML($this->decryptedDocument, 'saml-schema-protocol-2.0.xsd', $this->_settings->isDebugActive(), $this->_settings->getSchemasPath());
                        if (!$res instanceof DOMDocument) {
                            throw new ValidationError(
                                $errorXmlMsg,
                                ValidationError::INVALID_XML_FORMAT
                            );
                        }
                    }

                }

                $currentURL = Utils::getSelfRoutedURLNoQuery();

                $responseInResponseTo = null;
                if ($this->document->documentElement->hasAttribute('InResponseTo')) {
                    $responseInResponseTo = $this->document->documentElement->getAttribute('InResponseTo');
                }

                if (!isset($requestId) && isset($responseInResponseTo) && $security['rejectUnsolicitedResponsesWithInResponseTo']) {
                    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 matchs the ID of the AuthNRequest (requestId) if provided
                if (isset($requestId) && $requestId != $responseInResponseTo) {
                    if ($responseInResponseTo == null) {
                        throw new ValidationError(
                            "No InResponseTo at the Response, but it was provided the requestId related to the AuthNRequest sent by the SP: $requestId",
                            ValidationError::WRONG_INRESPONSETO
                        );
                    } else {
                        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 && $security['wantAssertionsEncrypted']) {
                    throw new ValidationError(
                        "The assertion of the Response is not encrypted and the SP requires it",
                        ValidationError::NO_ENCRYPTED_ASSERTION
                    );
                }

                if ($security['wantNameIdEncrypted']) {
                    $encryptedIdNodes = $this->_queryAssertion('/saml:Subject/saml:EncryptedID/xenc:EncryptedData');
                    if ($encryptedIdNodes->length != 1) {
                        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 Asserion timestamps
                $this->validateTimestamps();

                // 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
                $encryptedAttributeNodes = $this->_queryAssertion('/saml:AttributeStatement/saml:EncryptedAttribute');
                if ($encryptedAttributeNodes->length > 0) {
                    throw new ValidationError(
                        "There is an EncryptedAttribute in the Response and this SP not support them",
                        ValidationError::ENCRYPTED_ATTRIBUTES
                    );
                }

                // Check destination
                if ($this->document->documentElement->hasAttribute('Destination')) {
                    $destination = trim($this->document->documentElement->getAttribute('Destination'));
                    if (empty($destination)) {
                        if (!$security['relaxDestinationValidation']) {
                            throw new ValidationError(
                                "The response has an empty Destination value",
                                ValidationError::EMPTY_DESTINATION
                            );
                        }
                    } else {
                        $urlComparisonLength = $security['destinationStrictlyMatches'] ? strlen($destination) : strlen($currentURL);
                        if (strncmp($destination, $currentURL, $urlComparisonLength) !== 0) {
                            $currentURLNoRouted = Utils::getSelfURLNoQuery();
                            $urlComparisonLength = $security['destinationStrictlyMatches'] ? strlen($destination) : strlen($currentURLNoRouted);
                            if (strncmp($destination, $currentURLNoRouted, $urlComparisonLength) !== 0) {
                                throw new ValidationError(
                                    "The response was received at $currentURL instead of $destination",
                                    ValidationError::WRONG_DESTINATION
                                );
                            }
                        }
                    }
                }

                // Check audience
                $validAudiences = $this->getAudiences();
                if (!empty($validAudiences) && !in_array($spEntityId, $validAudiences, true)) {
                    throw new ValidationError(
                        sprintf(
                            "Invalid audience for this Response (expected '%s', got '%s')",
                            $spEntityId,
                            implode(',', $validAudiences)
                        ),
                        ValidationError::WRONG_AUDIENCE
                    );
                }

                // Check the issuers
                $issuers = $this->getIssuers();
                foreach ($issuers as $issuer) {
                    $trimmedIssuer = trim($issuer);
                    if (empty($trimmedIssuer) || $trimmedIssuer !== $idPEntityId) {
                        throw new ValidationError(
                            "Invalid issuer in the Assertion/Response (expected '$idPEntityId', got '$trimmedIssuer')",
                            ValidationError::WRONG_ISSUER
                        );
                    }
                }

                // Check the session Expiration
                $sessionExpiration = $this->getSessionNotOnOrAfter();
                if (!empty($sessionExpiration) && $sessionExpiration + Constants::ALLOWED_CLOCK_DRIFT <= time()) {
                    throw new ValidationError(
                        "The attributes have expired, based on the SessionNotOnOrAfter of the AttributeStatement of this Response",
                        ValidationError::SESSION_EXPIRED
                    );
                }

                // Check the SubjectConfirmation, at least one SubjectConfirmation must be valid
                $anySubjectConfirmation = false;
                $subjectConfirmationNodes = $this->_queryAssertion('/saml:Subject/saml:SubjectConfirmation');
                foreach ($subjectConfirmationNodes as $scn) {
                    if ($scn->hasAttribute('Method') && $scn->getAttribute('Method') != Constants::CM_BEARER) {
                        continue;
                    }
                    $subjectConfirmationDataNodes = $scn->getElementsByTagName('SubjectConfirmationData');
                    if ($subjectConfirmationDataNodes->length == 0) {
                        continue;
                    } else {
                        $scnData = $subjectConfirmationDataNodes->item(0);
                        if ($scnData->hasAttribute('InResponseTo')) {
                            $inResponseTo = $scnData->getAttribute('InResponseTo');
                            if (isset($responseInResponseTo) && $responseInResponseTo != $inResponseTo) {
                                continue;
                            }
                        }
                        if ($scnData->hasAttribute('Recipient')) {
                            $recipient = $scnData->getAttribute('Recipient');
                            if (!empty($recipient) && strpos($recipient, $currentURL) === false) {
                                continue;
                            }
                        }
                        if ($scnData->hasAttribute('NotOnOrAfter')) {
                            $noa = Utils::parseSAML2Time($scnData->getAttribute('NotOnOrAfter'));
                            if ($noa + Constants::ALLOWED_CLOCK_DRIFT <= time()) {
                                continue;
                            }
                        }
                        if ($scnData->hasAttribute('NotBefore')) {
                            $nb = Utils::parseSAML2Time($scnData->getAttribute('NotBefore'));
                            if ($nb > time() + Constants::ALLOWED_CLOCK_DRIFT) {
                                continue;
                            }
                        }

                        // Save NotOnOrAfter value
                        if ($scnData->hasAttribute('NotOnOrAfter')) {
                            $this->_validSCDNotOnOrAfter = $noa;
                        }
                        $anySubjectConfirmation = true;
                        break;
                    }
                }

                if (!$anySubjectConfirmation) {
                    throw new ValidationError(
                        "A valid SubjectConfirmation was not found on this Response",
                        ValidationError::WRONG_SUBJECTCONFIRMATION
                    );
                }

                if ($security['wantAssertionsSigned'] && !$hasSignedAssertion) {
                    throw new ValidationError(
                        "The Assertion of the Response is not signed and the SP requires it",
                        ValidationError::NO_SIGNED_ASSERTION
                    );
                }

                if ($security['wantMessagesSigned'] && !$hasSignedResponse) {
                    throw new ValidationError(
                        "The Message of the Response is not signed and the SP requires it",
                        ValidationError::NO_SIGNED_MESSAGE
                    );
                }
            }

            // Detect case not supported
            if ($this->encrypted) {
                $encryptedIDNodes = Utils::query($this->decryptedDocument, '/samlp:Response/saml:Assertion/saml:Subject/saml:EncryptedID');
                if ($encryptedIDNodes->length > 0) {
                    throw new ValidationError(
                        'SAML Response that contains an encrypted Assertion with encrypted nameId is not supported.',
                        ValidationError::NOT_SUPPORTED
                    );
                }
            }

            if (empty($signedElements) || (!$hasSignedResponse && !$hasSignedAssertion)) {
                throw new ValidationError(
                    'No Signature found. SAML Response rejected',
                    ValidationError::NO_SIGNATURE_FOUND
                );
            } else {
                $cert = $idpData['x509cert'];
                $fingerprint = $idpData['certFingerprint'];
                $fingerprintalg = $idpData['certFingerprintAlgorithm'];

                $multiCerts = null;
                $existsMultiX509Sign = isset($idpData['x509certMulti']) && isset($idpData['x509certMulti']['signing']) && !empty($idpData['x509certMulti']['signing']);

                if ($existsMultiX509Sign) {
                    $multiCerts = $idpData['x509certMulti']['signing'];
                }

                // If find a Signature on the Response, validates it checking the original response
                if ($hasSignedResponse && !Utils::validateSign($this->document, $cert, $fingerprint, $fingerprintalg, Utils::RESPONSE_SIGNATURE_XPATH, $multiCerts)) {
                    throw new ValidationError(
                        "Signature validation failed. SAML Response rejected",
                        ValidationError::INVALID_SIGNATURE
                    );
                }

                // If find a Signature on the Assertion (decrypted assertion if was encrypted)
                $documentToCheckAssertion = $this->encrypted ? $this->decryptedDocument : $this->document;
                if ($hasSignedAssertion && !Utils::validateSign($documentToCheckAssertion, $cert, $fingerprint, $fingerprintalg, Utils::ASSERTION_SIGNATURE_XPATH, $multiCerts)) {
                    throw new ValidationError(
                        "Signature validation failed. SAML Response rejected",
                        ValidationError::INVALID_SIGNATURE
                    );
                }
            }
            return true;
        } catch (Exception $e) {
            $this->_error = $e;
            $debug = $this->_settings->isDebugActive();
            if ($debug) {
                echo htmlentities($e->getMessage());
            }
            return false;
        }
    }

    /**
     * @return string|null the ID of the Response
     */
    public function getId()
    {
        $id = null;
        if ($this->document->documentElement->hasAttribute('ID')) {
            $id = $this->document->documentElement->getAttribute('ID');
        }
        return $id;
    }

    /**
     * @return string|null the ID of the assertion in the Response
     *
     * @throws ValidationError
     */
    public function getAssertionId()
    {
        if (!$this->validateNumAssertions()) {
            throw new ValidationError("SAML Response must contain 1 Assertion.", ValidationError::WRONG_NUMBER_OF_ASSERTIONS);
        }
        $assertionNodes = $this->_queryAssertion("");
        $id = null;
        if ($assertionNodes->length == 1 && $assertionNodes->item(0)->hasAttribute('ID')) {
            $id = $assertionNodes->item(0)->getAttribute('ID');
        }
        return $id;
    }

    /**
     * @return int the NotOnOrAfter value of the valid SubjectConfirmationData
     * node if any
     */
    public function getAssertionNotOnOrAfter()
    {
        return $this->_validSCDNotOnOrAfter;
    }

    /**
     * Checks if the Status is success
     *
     * @throws ValidationError If status is not success
     */
    public function checkStatus()
    {
        $status = Utils::getStatus($this->document);

        if (isset($status['code']) && $status['code'] !== Constants::STATUS_SUCCESS) {
            $explodedCode = explode(':', $status['code']);
            $printableCode = array_pop($explodedCode);

            $statusExceptionMsg = 'The status code of the Response was not Success, was '.$printableCode;
            if (!empty($status['msg'])) {
                $statusExceptionMsg .= ' -> '.$status['msg'];
            }
            throw new ValidationError(
                $statusExceptionMsg,
                ValidationError::STATUS_CODE_IS_NOT_SUCCESS
            );
        }
    }

    /**
     * Checks that the samlp:Response/saml:Assertion/saml:Conditions element exists and is unique.
     *
     * @return boolean true if the Conditions element exists and is unique
     */
    public function checkOneCondition()
    {
        $entries = $this->_queryAssertion("/saml:Conditions");
        if ($entries->length == 1) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Checks that the samlp:Response/saml:Assertion/saml:AuthnStatement element exists and is unique.
     *
     * @return boolean true if the AuthnStatement element exists and is unique
     */
    public function checkOneAuthnStatement()
    {
        $entries = $this->_queryAssertion("/saml:AuthnStatement");
        if ($entries->length == 1) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Gets the audiences.
     *
     * @return array @audience The valid audiences of the response
     */
    public function getAudiences()
    {
        $audiences = array();

        $entries = $this->_queryAssertion('/saml:Conditions/saml:AudienceRestriction/saml:Audience');
        foreach ($entries as $entry) {
            $value = trim($entry->textContent);
            if (!empty($value)) {
                $audiences[] = $value;
            }
        }

        return array_unique($audiences);
    }

    /**
     * Gets the Issuers (from Response and Assertion).
     *
     * @return array @issuers The issuers of the assertion/response
     *
     * @throws ValidationError
     */
    public function getIssuers()
    {
        $issuers = array();

        $responseIssuer = Utils::query($this->document, '/samlp:Response/saml:Issuer');
        if ($responseIssuer->length > 0) {
            if ($responseIssuer->length == 1) {
                $issuers[] = $responseIssuer->item(0)->textContent;
            } else {
                throw new ValidationError(
                    "Issuer of the Response is multiple.",
                    ValidationError::ISSUER_MULTIPLE_IN_RESPONSE
                );
            }
        }

        $assertionIssuer = $this->_queryAssertion('/saml:Issuer');
        if ($assertionIssuer->length == 1) {
            $issuers[] = $assertionIssuer->item(0)->textContent;
        } else {
            throw new ValidationError(
                "Issuer of the Assertion not found or multiple.",
                ValidationError::ISSUER_NOT_FOUND_IN_ASSERTION
            );
        }

        return array_unique($issuers);
    }

    /**
     * Gets the NameID Data provided by the SAML response from the IdP.
     *
     * @return array Name ID Data (Value, Format, NameQualifier, SPNameQualifier)
     *
     * @throws ValidationError
     */
    public function getNameIdData()
    {
        $encryptedIdDataEntries = $this->_queryAssertion('/saml:Subject/saml:EncryptedID/xenc:EncryptedData');

        if ($encryptedIdDataEntries->length == 1) {
            $encryptedData = $encryptedIdDataEntries->item(0);

            $key = $this->_settings->getSPkey();
            $seckey = new XMLSecurityKey(XMLSecurityKey::RSA_1_5, array('type'=>'private'));
            $seckey->loadKey($key);

            $nameId = Utils::decryptElement($encryptedData, $seckey);

        } else {
            $entries = $this->_queryAssertion('/saml:Subject/saml:NameID');
            if ($entries->length == 1) {
                $nameId = $entries->item(0);
            }
        }

        $nameIdData = array();

        if (!isset($nameId)) {
            $security = $this->_settings->getSecurityData();
            if ($security['wantNameId']) {
                throw new ValidationError(
                    "NameID not found in the assertion of the Response",
                    ValidationError::NO_NAMEID
                );
            }
        } else {
            if ($this->_settings->isStrict() && empty($nameId->nodeValue)) {
                throw new ValidationError(
                    "An empty NameID value found",
                    ValidationError::EMPTY_NAMEID
                );
            }
            $nameIdData['Value'] = $nameId->nodeValue;

            foreach (array('Format', 'SPNameQualifier', 'NameQualifier') as $attr) {
                if ($nameId->hasAttribute($attr)) {
                    if ($this->_settings->isStrict() && $attr == 'SPNameQualifier') {
                        $spData = $this->_settings->getSPData();
                        $spEntityId = $spData['entityId'];
                        if ($spEntityId != $nameId->getAttribute($attr)) {
                            throw new ValidationError(
                                "The SPNameQualifier value mistmatch the SP entityID value.",
                                ValidationError::SP_NAME_QUALIFIER_NAME_MISMATCH
                            );
                        }
                    }
                    $nameIdData[$attr] = $nameId->getAttribute($attr);
                }
            }
        }

        return $nameIdData;
    }

    /**
     * Gets the NameID provided by the SAML response from the IdP.
     *
     * @return string|null Name ID Value
     *
     * @throws ValidationError
     */
    public function getNameId()
    {
        $nameIdvalue = null;
        $nameIdData = $this->getNameIdData();
        if (!empty($nameIdData) && isset($nameIdData['Value'])) {
            $nameIdvalue = $nameIdData['Value'];
        }
        return $nameIdvalue;
    }

    /**
     * Gets the NameID Format provided by the SAML response from the IdP.
     *
     * @return string|null Name ID Format
     *
     * @throws ValidationError
     */
    public function getNameIdFormat()
    {
        $nameIdFormat = null;
        $nameIdData = $this->getNameIdData();
        if (!empty($nameIdData) && isset($nameIdData['Format'])) {
            $nameIdFormat = $nameIdData['Format'];
        }
        return $nameIdFormat;
    }

    /**
     * Gets the NameID NameQualifier provided by the SAML response from the IdP.
     *
     * @return string|null Name ID NameQualifier
     *
     * @throws ValidationError
     */
    public function getNameIdNameQualifier()
    {
        $nameIdNameQualifier = null;
        $nameIdData = $this->getNameIdData();
        if (!empty($nameIdData) && isset($nameIdData['NameQualifier'])) {
            $nameIdNameQualifier = $nameIdData['NameQualifier'];
        }
        return $nameIdNameQualifier;
    }

    /**
     * Gets the NameID SP NameQualifier provided by the SAML response from the IdP.
     *
     * @return string|null NameID SP NameQualifier
     *
     * @throws ValidationError
     */
    public function getNameIdSPNameQualifier()
    {
        $nameIdSPNameQualifier = null;
        $nameIdData = $this->getNameIdData();
        if (!empty($nameIdData) && isset($nameIdData['SPNameQualifier'])) {
            $nameIdSPNameQualifier = $nameIdData['SPNameQualifier'];
        }
        return $nameIdSPNameQualifier;
    }

    /**
     * Gets the SessionNotOnOrAfter from the AuthnStatement.
     * Could be used to set the local session expiration
     *
     * @return int|null The SessionNotOnOrAfter value
     *
     * @throws Exception
     */
    public function getSessionNotOnOrAfter()
    {
        $notOnOrAfter = null;
        $entries = $this->_queryAssertion('/saml:AuthnStatement[@SessionNotOnOrAfter]');
        if ($entries->length !== 0) {
            $notOnOrAfter = Utils::parseSAML2Time($entries->item(0)->getAttribute('SessionNotOnOrAfter'));
        }
        return $notOnOrAfter;
    }

    /**
     * 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 string|null The SessionIndex value
     */
    public function getSessionIndex()
    {
        $sessionIndex = null;
        $entries = $this->_queryAssertion('/saml:AuthnStatement[@SessionIndex]');
        if ($entries->length !== 0) {
            $sessionIndex = $entries->item(0)->getAttribute('SessionIndex');
        }
        return $sessionIndex;
    }

    /**
     * Gets the Attributes from the AttributeStatement element.
     *
     * @return array The attributes of the SAML Assertion
     *
     * @throws ValidationError
     */
    public function getAttributes()
    {
        return $this->_getAttributesByKeyName('Name');
    }

    /**
     * Gets the Attributes from the AttributeStatement element using their FriendlyName.
     *
     * @return array The attributes of the SAML Assertion
     *
     * @throws ValidationError
     */
    public function getAttributesWithFriendlyName()
    {
        return $this->_getAttributesByKeyName('FriendlyName');
    }

    /**
     * @param string $keyName
     *
     * @return array
     *
     * @throws ValidationError
     */
    private function _getAttributesByKeyName($keyName = "Name")
    {
        $attributes = array();
        $entries = $this->_queryAssertion('/saml:AttributeStatement/saml:Attribute');

        $security = $this->_settings->getSecurityData();
        $allowRepeatAttributeName = $security['allowRepeatAttributeName'];
        /** @var $entry DOMNode */
        foreach ($entries as $entry) {
            $attributeKeyNode = $entry->attributes->getNamedItem($keyName);
            if ($attributeKeyNode === null) {
                continue;
            }
            $attributeKeyName = $attributeKeyNode->nodeValue;
            if (in_array($attributeKeyName, array_keys($attributes))) {
                if (!$allowRepeatAttributeName) {
                    throw new ValidationError(
                        "Found an Attribute element with duplicated ".$keyName,
                        ValidationError::DUPLICATED_ATTRIBUTE_NAME_FOUND
                    );
                }
            }
            $attributeValues = array();
            foreach ($entry->childNodes as $childNode) {
                $tagName = ($childNode->prefix ? $childNode->prefix.':' : '') . 'AttributeValue';
                if ($childNode->nodeType == XML_ELEMENT_NODE && $childNode->tagName === $tagName) {
                    $attributeValues[] = $childNode->nodeValue;
                }
            }

            if (in_array($attributeKeyName, array_keys($attributes))) {
                $attributes[$attributeKeyName] = array_merge($attributes[$attributeKeyName], $attributeValues);
            } else {
                $attributes[$attributeKeyName] = $attributeValues;
            }
        }
        return $attributes;
    }

    /**
     * Verifies that the document only contains a single Assertion (encrypted or not).
     *
     * @return bool TRUE if the document passes.
     */
    public function validateNumAssertions()
    {
        $encryptedAssertionNodes = $this->document->getElementsByTagName('EncryptedAssertion');
        $assertionNodes = $this->document->getElementsByTagName('Assertion');

        $valid = $assertionNodes->length + $encryptedAssertionNodes->length == 1;

        if ($this->encrypted) {
            $assertionNodes = $this->decryptedDocument->getElementsByTagName('Assertion');
            $valid = $valid && $assertionNodes->length == 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 ValidationError
     */
    public function processSignedElements()
    {
        $signedElements = array();
        $verifiedSeis = array();
        $verifiedIds = array();

        if ($this->encrypted) {
            $signNodes = $this->decryptedDocument->getElementsByTagName('Signature');
        } else {
            $signNodes = $this->document->getElementsByTagName('Signature');
        }
        foreach ($signNodes as $signNode) {
            $responseTag = '{'.Constants::NS_SAMLP.'}Response';
            $assertionTag = '{'.Constants::NS_SAML.'}Assertion';

            $signedElement = '{'.$signNode->parentNode->namespaceURI.'}'.$signNode->parentNode->localName;

            if ($signedElement != $responseTag && $signedElement != $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
            $idValue = $signNode->parentNode->getAttribute('ID');
            if (empty($idValue)) {
                throw new ValidationError(
                    'Signed Element must contain an ID. SAML Response rejected',
                    ValidationError::ID_NOT_FOUND_IN_SIGNED_ELEMENT
                );
            }

            if (in_array($idValue, $verifiedIds)) {
                throw new ValidationError(
                    'Duplicated ID. SAML Response rejected',
                    ValidationError::DUPLICATED_ID_IN_SIGNED_ELEMENTS
                );
            }
            $verifiedIds[] = $idValue;

            $ref = $signNode->getElementsByTagName('Reference');
            if ($ref->length == 1) {
                $ref = $ref->item(0);
                $sei = $ref->getAttribute('URI');
                if (!empty($sei)) {
                    $sei = substr($sei, 1);

                    if ($sei != $idValue) {
                        throw new ValidationError(
                            'Found an invalid Signed Element. SAML Response rejected',
                            ValidationError::INVALID_SIGNED_ELEMENT
                        );
                    }

                    if (in_array($sei, $verifiedSeis)) {
                        throw new ValidationError(
                            'Duplicated Reference URI. SAML Response rejected',
                            ValidationError::DUPLICATED_REFERENCE_IN_SIGNED_ELEMENTS
                        );
                    }
                    $verifiedSeis[] = $sei;
                }
            } else {
                throw new ValidationError(
                    'Unexpected number of Reference nodes found for signature. SAML Response rejected.',
                    ValidationError::UNEXPECTED_REFERENCE
                );
            }
            $signedElements[] = $signedElement;
        }

        // Check SignedElements
        if (!empty($signedElements) && !$this->validateSignedElements($signedElements)) {
            throw new ValidationError(
                'Found an unexpected Signature Element. SAML Response rejected',
                ValidationError::UNEXPECTED_SIGNED_ELEMENTS
            );
        }
        return $signedElements;
    }

    /**
     * Verifies that the document is still valid according Conditions Element.
     *
     * @return bool
     *
     * @throws Exception
     * @throws ValidationError
     */
    public function validateTimestamps()
    {
        if ($this->encrypted) {
            $document = $this->decryptedDocument;
        } else {
            $document = $this->document;
        }

        $timestampNodes = $document->getElementsByTagName('Conditions');
        for ($i = 0; $i < $timestampNodes->length; $i++) {
            $nbAttribute = $timestampNodes->item($i)->attributes->getNamedItem("NotBefore");
            $naAttribute = $timestampNodes->item($i)->attributes->getNamedItem("NotOnOrAfter");
            if ($nbAttribute && Utils::parseSAML2Time($nbAttribute->textContent) > time() + Constants::ALLOWED_CLOCK_DRIFT) {
                throw new ValidationError(
                    'Could not validate timestamp: not yet valid. Check system clock.',
                    ValidationError::ASSERTION_TOO_EARLY
                );
            }
            if ($naAttribute && Utils::parseSAML2Time($naAttribute->textContent) + Constants::ALLOWED_CLOCK_DRIFT <= time()) {
                throw new ValidationError(
                    'Could not validate timestamp: expired. Check system clock.',
                    ValidationError::ASSERTION_EXPIRED
                );
            }
        }
        return true;
    }

    /**
     * Verifies that the document has the expected signed nodes.
     *
     * @param array $signedElements Signed elements
     *
     * @return bool
     *
     * @throws ValidationError
     */
    public function validateSignedElements($signedElements)
    {
        if (count($signedElements) > 2) {
            return false;
        }

        $responseTag = '{'.Constants::NS_SAMLP.'}Response';
        $assertionTag = '{'.Constants::NS_SAML.'}Assertion';

        $ocurrence = array_count_values($signedElements);
        if ((in_array($responseTag, $signedElements) && $ocurrence[$responseTag] > 1)
            || (in_array($assertionTag, $signedElements) && $ocurrence[$assertionTag] > 1)
            || !in_array($responseTag, $signedElements) && !in_array($assertionTag, $signedElements)
        ) {
            return false;
        }

        // Check that the signed elements found here, are the ones that will be verified
        // by Utils->validateSign()
        if (in_array($responseTag, $signedElements)) {
            $expectedSignatureNodes = Utils::query($this->document, Utils::RESPONSE_SIGNATURE_XPATH);
            if ($expectedSignatureNodes->length != 1) {
                throw new ValidationError(
                    "Unexpected number of Response signatures found. SAML Response rejected.",
                    ValidationError::WRONG_NUMBER_OF_SIGNATURES_IN_RESPONSE
                );
            }
        }

        if (in_array($assertionTag, $signedElements)) {
            $expectedSignatureNodes = $this->_query(Utils::ASSERTION_SIGNATURE_XPATH);
            if ($expectedSignatureNodes->length != 1) {
                throw new ValidationError(
                    "Unexpected number of Assertion signatures found. SAML Response rejected.",
                    ValidationError::WRONG_NUMBER_OF_SIGNATURES_IN_ASSERTION
                );
            }
        }

        return true;
    }

    /**
     * Extracts a node from the DOMDocument (Assertion).
     *
     * @param string $assertionXpath Xpath Expression
     *
     * @return DOMNodeList The queried node
     */
    protected function _queryAssertion($assertionXpath)
    {
        if ($this->encrypted) {
            $xpath = new DOMXPath($this->decryptedDocument);
        } else {
            $xpath = new DOMXPath($this->document);
        }

        $xpath->registerNamespace('samlp', Constants::NS_SAMLP);
        $xpath->registerNamespace('saml', Constants::NS_SAML);
        $xpath->registerNamespace('ds', Constants::NS_DS);
        $xpath->registerNamespace('xenc', Constants::NS_XENC);

        $assertionNode = '/samlp:Response/saml:Assertion';
        $signatureQuery = $assertionNode . '/ds:Signature/ds:SignedInfo/ds:Reference';
        $assertionReferenceNode = $xpath->query($signatureQuery)->item(0);
        if (!$assertionReferenceNode) {
            // is the response signed as a whole?
            $signatureQuery = '/samlp:Response/ds:Signature/ds:SignedInfo/ds:Reference';
            $responseReferenceNode = $xpath->query($signatureQuery)->item(0);
            if ($responseReferenceNode) {
                $uri = $responseReferenceNode->attributes->getNamedItem('URI')->nodeValue;
                if (empty($uri)) {
                    $id = $responseReferenceNode->parentNode->parentNode->parentNode->attributes->getNamedItem('ID')->nodeValue;
                } else {
                    $id = substr($responseReferenceNode->attributes->getNamedItem('URI')->nodeValue, 1);
                }
                $nameQuery = "/samlp:Response[@ID='$id']/saml:Assertion" . $assertionXpath;
            } else {
                $nameQuery = "/samlp:Response/saml:Assertion" . $assertionXpath;
            }
        } else {
            $uri = $assertionReferenceNode->attributes->getNamedItem('URI')->nodeValue;
            if (empty($uri)) {
                $id = $assertionReferenceNode->parentNode->parentNode->parentNode->attributes->getNamedItem('ID')->nodeValue;
            } else {
                $id = substr($assertionReferenceNode->attributes->getNamedItem('URI')->nodeValue, 1);
            }
            $nameQuery = $assertionNode."[@ID='$id']" . $assertionXpath;
        }

        return $xpath->query($nameQuery);
    }

    /**
     * Extracts nodes that match the query from the DOMDocument (Response Menssage)
     *
     * @param string $query Xpath Expression
     *
     * @return DOMNodeList The queried nodes
     */
    private function _query($query)
    {
        if ($this->encrypted) {
            return Utils::query($this->decryptedDocument, $query);
        } else {
            return Utils::query($this->document, $query);
        }
    }

    /**
     * Decrypts the Assertion (DOMDocument)
     *
     * @param \DomNode $dom DomDocument
     *
     * @return DOMDocument Decrypted Assertion
     *
     * @throws Exception
     * @throws ValidationError
     */
    protected function decryptAssertion(\DomNode $dom)
    {
        $pem = $this->_settings->getSPkey();

        if (empty($pem)) {
            throw new Error(
                "No private key available, check settings",
                Error::PRIVATE_KEY_NOT_FOUND
            );
        }

        $objenc = new XMLSecEnc();
        $encData = $objenc->locateEncryptedData($dom);
        if (!$encData) {
            throw new ValidationError(
                "Cannot locate encrypted assertion",
                ValidationError::MISSING_ENCRYPTED_ELEMENT
            );
        }

        $objenc->setNode($encData);
        $objenc->type = $encData->getAttribute("Type");
        if (!$objKey = $objenc->locateKey()) {
            throw new ValidationError(
                "Unknown algorithm",
                ValidationError::KEY_ALGORITHM_ERROR
            );
        }

        $key = null;
        if ($objKeyInfo = $objenc->locateKeyInfo($objKey)) {
            if ($objKeyInfo->isEncrypted) {
                $objencKey = $objKeyInfo->encryptedCtx;
                $objKeyInfo->loadKey($pem, false, false);
                $key = $objencKey->decryptKey($objKeyInfo);
            } else {
                // symmetric encryption key support
                $objKeyInfo->loadKey($pem, false, false);
            }
        }

        if (empty($objKey->key)) {
            $objKey->loadKey($key);
        }

        $decryptedXML = $objenc->decryptNode($objKey, false);
        $decrypted = new DOMDocument();
        $check = Utils::loadXML($decrypted, $decryptedXML);
        if ($check === false) {
            throw new Exception('Error: string from decrypted assertion could not be loaded into a XML document');
        }
        if ($encData->parentNode instanceof DOMDocument) {
            return $decrypted;
        } else {
            $decrypted = $decrypted->documentElement;
            $encryptedAssertion = $encData->parentNode;
            $container = $encryptedAssertion->parentNode;

            // Fix possible issue with saml namespace
            if (!$decrypted->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:saml')
                && !$decrypted->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:saml2')
                && !$decrypted->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns')
                && !$container->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:saml')
                && !$container->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:saml2')
            ) {
                if (strpos($encryptedAssertion->tagName, 'saml2:') !== false) {
                    $ns = 'xmlns:saml2';
                } else if (strpos($encryptedAssertion->tagName, 'saml:') !== false) {
                    $ns = 'xmlns:saml';
                } else {
                    $ns = 'xmlns';
                }
                $decrypted->setAttributeNS('http://www.w3.org/2000/xmlns/', $ns, Constants::NS_SAML);
            }

            Utils::treeCopyReplace($encryptedAssertion, $decrypted);

            // Rebuild the DOM will fix issues with namespaces as well
            $dom = new DOMDocument();
            return Utils::loadXML($dom, $container->ownerDocument->saveXML());
        }
    }

    /**
     * After execute a validation process, if fails this method returns the cause
     *
     * @return Exception|null Cause
     */
    public function getErrorException()
    {
        return $this->_error;
    }

    /**
     * After execute a validation process, if fails this method returns the cause
     *
     * @return null|string Error reason
     */
    public function getError()
    {
        $errorMsg = null;
        if (isset($this->_error)) {
            $errorMsg = htmlentities($this->_error->getMessage());
        }
        return $errorMsg;
    }

    /**
     * Returns the SAML Response document (If contains an encrypted assertion, decrypts it)
     *
     * @return DomDocument SAML Response
     */
    public function getXMLDocument()
    {
        if ($this->encrypted) {
            return $this->decryptedDocument;
        } else {
            return $this->document;
        }
    }
}
