1<?php
2/**
3 * This file is part of php-saml.
4 *
5 * (c) OneLogin Inc
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 *
10 * @package OneLogin
11 * @author  OneLogin Inc <saml-info@onelogin.com>
12 * @license MIT https://github.com/onelogin/php-saml/blob/master/LICENSE
13 * @link    https://github.com/onelogin/php-saml
14 */
15
16namespace OneLogin\Saml2;
17
18use RobRichards\XMLSecLibs\XMLSecurityKey;
19use RobRichards\XMLSecLibs\XMLSecEnc;
20
21use DOMDocument;
22use DOMNodeList;
23use DOMXPath;
24use Exception;
25
26/**
27 * SAML 2 Authentication Response
28 */
29class Response
30{
31    /**
32     * Settings
33     *
34     * @var Settings
35     */
36    protected $_settings;
37
38    /**
39     * The decoded, unprocessed XML response provided to the constructor.
40     *
41     * @var string
42     */
43    public $response;
44
45    /**
46     * A DOMDocument class loaded from the SAML Response.
47     *
48     * @var DOMDocument
49     */
50    public $document;
51
52    /**
53     * A DOMDocument class loaded from the SAML Response (Decrypted).
54     *
55     * @var DOMDocument
56     */
57    public $decryptedDocument;
58
59    /**
60     * The response contains an encrypted assertion.
61     *
62     * @var bool
63     */
64    public $encrypted = false;
65
66    /**
67     * After validation, if it fail this var has the cause of the problem
68     *
69     * @var Exception|null
70     */
71    private $_error;
72
73    /**
74     * NotOnOrAfter value of a valid SubjectConfirmationData node
75     *
76     * @var int
77     */
78    private $_validSCDNotOnOrAfter;
79
80    /**
81     * Constructs the SAML Response object.
82     *
83     * @param Settings $settings Settings.
84     * @param string   $response A UUEncoded SAML response from the IdP.
85     *
86     * @throws Exception
87     * @throws ValidationError
88     */
89    public function __construct(\OneLogin\Saml2\Settings $settings, $response)
90    {
91        $this->_settings = $settings;
92
93        $baseURL = $this->_settings->getBaseURL();
94        if (!empty($baseURL)) {
95            Utils::setBaseURL($baseURL);
96        }
97
98        $this->response = base64_decode($response);
99
100        $this->document = new DOMDocument();
101        $this->document = Utils::loadXML($this->document, $this->response);
102        if (!$this->document) {
103            throw new ValidationError(
104                "SAML Response could not be processed",
105                ValidationError::INVALID_XML_FORMAT
106            );
107        }
108
109        // Quick check for the presence of EncryptedAssertion
110        $encryptedAssertionNodes = $this->document->getElementsByTagName('EncryptedAssertion');
111        if ($encryptedAssertionNodes->length !== 0) {
112            $this->decryptedDocument = clone $this->document;
113            $this->encrypted = true;
114            $this->decryptedDocument = $this->decryptAssertion($this->decryptedDocument);
115        }
116    }
117
118    /**
119     * Determines if the SAML Response is valid using the certificate.
120     *
121     * @param string|null $requestId The ID of the AuthNRequest sent by this SP to the IdP
122     *
123     * @return bool Validate the document
124     *
125     * @throws Exception
126     * @throws ValidationError
127     */
128    public function isValid($requestId = null)
129    {
130        $this->_error = null;
131        try {
132            // Check SAML version
133            if ($this->document->documentElement->getAttribute('Version') != '2.0') {
134                throw new ValidationError(
135                    "Unsupported SAML version",
136                    ValidationError::UNSUPPORTED_SAML_VERSION
137                );
138            }
139
140            if (!$this->document->documentElement->hasAttribute('ID')) {
141                throw new ValidationError(
142                    "Missing ID attribute on SAML Response",
143                    ValidationError::MISSING_ID
144                );
145            }
146
147            $this->checkStatus();
148
149            $singleAssertion = $this->validateNumAssertions();
150            if (!$singleAssertion) {
151                throw new ValidationError(
152                    "SAML Response must contain 1 assertion",
153                    ValidationError::WRONG_NUMBER_OF_ASSERTIONS
154                );
155            }
156
157            $idpData = $this->_settings->getIdPData();
158            $idPEntityId = $idpData['entityId'];
159            $spData = $this->_settings->getSPData();
160            $spEntityId = $spData['entityId'];
161
162            $signedElements = $this->processSignedElements();
163
164            $responseTag = '{'.Constants::NS_SAMLP.'}Response';
165            $assertionTag = '{'.Constants::NS_SAML.'}Assertion';
166
167            $hasSignedResponse = in_array($responseTag, $signedElements);
168            $hasSignedAssertion = in_array($assertionTag, $signedElements);
169
170            if ($this->_settings->isStrict()) {
171                $security = $this->_settings->getSecurityData();
172
173                if ($security['wantXMLValidation']) {
174                    $errorXmlMsg = "Invalid SAML Response. Not match the saml-schema-protocol-2.0.xsd";
175                    $res = Utils::validateXML($this->document, 'saml-schema-protocol-2.0.xsd', $this->_settings->isDebugActive(), $this->_settings->getSchemasPath());
176                    if (!$res instanceof DOMDocument) {
177                        throw new ValidationError(
178                            $errorXmlMsg,
179                            ValidationError::INVALID_XML_FORMAT
180                        );
181                    }
182
183                    // If encrypted, check also the decrypted document
184                    if ($this->encrypted) {
185                        $res = Utils::validateXML($this->decryptedDocument, 'saml-schema-protocol-2.0.xsd', $this->_settings->isDebugActive(), $this->_settings->getSchemasPath());
186                        if (!$res instanceof DOMDocument) {
187                            throw new ValidationError(
188                                $errorXmlMsg,
189                                ValidationError::INVALID_XML_FORMAT
190                            );
191                        }
192                    }
193
194                }
195
196                $currentURL = Utils::getSelfRoutedURLNoQuery();
197
198                $responseInResponseTo = null;
199                if ($this->document->documentElement->hasAttribute('InResponseTo')) {
200                    $responseInResponseTo = $this->document->documentElement->getAttribute('InResponseTo');
201                }
202
203                if (!isset($requestId) && isset($responseInResponseTo) && $security['rejectUnsolicitedResponsesWithInResponseTo']) {
204                    throw new ValidationError(
205                        "The Response has an InResponseTo attribute: " . $responseInResponseTo . " while no InResponseTo was expected",
206                        ValidationError::WRONG_INRESPONSETO
207                    );
208                }
209
210                // Check if the InResponseTo of the Response matchs the ID of the AuthNRequest (requestId) if provided
211                if (isset($requestId) && $requestId != $responseInResponseTo) {
212                    if ($responseInResponseTo == null) {
213                        throw new ValidationError(
214                            "No InResponseTo at the Response, but it was provided the requestId related to the AuthNRequest sent by the SP: $requestId",
215                            ValidationError::WRONG_INRESPONSETO
216                        );
217                    } else {
218                        throw new ValidationError(
219                            "The InResponseTo of the Response: $responseInResponseTo, does not match the ID of the AuthNRequest sent by the SP: $requestId",
220                            ValidationError::WRONG_INRESPONSETO
221                        );
222                    }
223                }
224
225                if (!$this->encrypted && $security['wantAssertionsEncrypted']) {
226                    throw new ValidationError(
227                        "The assertion of the Response is not encrypted and the SP requires it",
228                        ValidationError::NO_ENCRYPTED_ASSERTION
229                    );
230                }
231
232                if ($security['wantNameIdEncrypted']) {
233                    $encryptedIdNodes = $this->_queryAssertion('/saml:Subject/saml:EncryptedID/xenc:EncryptedData');
234                    if ($encryptedIdNodes->length != 1) {
235                        throw new ValidationError(
236                            "The NameID of the Response is not encrypted and the SP requires it",
237                            ValidationError::NO_ENCRYPTED_NAMEID
238                        );
239                    }
240                }
241
242                // Validate Conditions element exists
243                if (!$this->checkOneCondition()) {
244                    throw new ValidationError(
245                        "The Assertion must include a Conditions element",
246                        ValidationError::MISSING_CONDITIONS
247                    );
248                }
249
250                // Validate Asserion timestamps
251                $this->validateTimestamps();
252
253                // Validate AuthnStatement element exists and is unique
254                if (!$this->checkOneAuthnStatement()) {
255                    throw new ValidationError(
256                        "The Assertion must include an AuthnStatement element",
257                        ValidationError::WRONG_NUMBER_OF_AUTHSTATEMENTS
258                    );
259                }
260
261                // EncryptedAttributes are not supported
262                $encryptedAttributeNodes = $this->_queryAssertion('/saml:AttributeStatement/saml:EncryptedAttribute');
263                if ($encryptedAttributeNodes->length > 0) {
264                    throw new ValidationError(
265                        "There is an EncryptedAttribute in the Response and this SP not support them",
266                        ValidationError::ENCRYPTED_ATTRIBUTES
267                    );
268                }
269
270                // Check destination
271                if ($this->document->documentElement->hasAttribute('Destination')) {
272                    $destination = trim($this->document->documentElement->getAttribute('Destination'));
273                    if (empty($destination)) {
274                        if (!$security['relaxDestinationValidation']) {
275                            throw new ValidationError(
276                                "The response has an empty Destination value",
277                                ValidationError::EMPTY_DESTINATION
278                            );
279                        }
280                    } else {
281                        $urlComparisonLength = $security['destinationStrictlyMatches'] ? strlen($destination) : strlen($currentURL);
282                        if (strncmp($destination, $currentURL, $urlComparisonLength) !== 0) {
283                            $currentURLNoRouted = Utils::getSelfURLNoQuery();
284                            $urlComparisonLength = $security['destinationStrictlyMatches'] ? strlen($destination) : strlen($currentURLNoRouted);
285                            if (strncmp($destination, $currentURLNoRouted, $urlComparisonLength) !== 0) {
286                                throw new ValidationError(
287                                    "The response was received at $currentURL instead of $destination",
288                                    ValidationError::WRONG_DESTINATION
289                                );
290                            }
291                        }
292                    }
293                }
294
295                // Check audience
296                $validAudiences = $this->getAudiences();
297                if (!empty($validAudiences) && !in_array($spEntityId, $validAudiences, true)) {
298                    throw new ValidationError(
299                        sprintf(
300                            "Invalid audience for this Response (expected '%s', got '%s')",
301                            $spEntityId,
302                            implode(',', $validAudiences)
303                        ),
304                        ValidationError::WRONG_AUDIENCE
305                    );
306                }
307
308                // Check the issuers
309                $issuers = $this->getIssuers();
310                foreach ($issuers as $issuer) {
311                    $trimmedIssuer = trim($issuer);
312                    if (empty($trimmedIssuer) || $trimmedIssuer !== $idPEntityId) {
313                        throw new ValidationError(
314                            "Invalid issuer in the Assertion/Response (expected '$idPEntityId', got '$trimmedIssuer')",
315                            ValidationError::WRONG_ISSUER
316                        );
317                    }
318                }
319
320                // Check the session Expiration
321                $sessionExpiration = $this->getSessionNotOnOrAfter();
322                if (!empty($sessionExpiration) && $sessionExpiration + Constants::ALLOWED_CLOCK_DRIFT <= time()) {
323                    throw new ValidationError(
324                        "The attributes have expired, based on the SessionNotOnOrAfter of the AttributeStatement of this Response",
325                        ValidationError::SESSION_EXPIRED
326                    );
327                }
328
329                // Check the SubjectConfirmation, at least one SubjectConfirmation must be valid
330                $anySubjectConfirmation = false;
331                $subjectConfirmationNodes = $this->_queryAssertion('/saml:Subject/saml:SubjectConfirmation');
332                foreach ($subjectConfirmationNodes as $scn) {
333                    if ($scn->hasAttribute('Method') && $scn->getAttribute('Method') != Constants::CM_BEARER) {
334                        continue;
335                    }
336                    $subjectConfirmationDataNodes = $scn->getElementsByTagName('SubjectConfirmationData');
337                    if ($subjectConfirmationDataNodes->length == 0) {
338                        continue;
339                    } else {
340                        $scnData = $subjectConfirmationDataNodes->item(0);
341                        if ($scnData->hasAttribute('InResponseTo')) {
342                            $inResponseTo = $scnData->getAttribute('InResponseTo');
343                            if (isset($responseInResponseTo) && $responseInResponseTo != $inResponseTo) {
344                                continue;
345                            }
346                        }
347                        if ($scnData->hasAttribute('Recipient')) {
348                            $recipient = $scnData->getAttribute('Recipient');
349                            if (!empty($recipient) && strpos($recipient, $currentURL) === false) {
350                                continue;
351                            }
352                        }
353                        if ($scnData->hasAttribute('NotOnOrAfter')) {
354                            $noa = Utils::parseSAML2Time($scnData->getAttribute('NotOnOrAfter'));
355                            if ($noa + Constants::ALLOWED_CLOCK_DRIFT <= time()) {
356                                continue;
357                            }
358                        }
359                        if ($scnData->hasAttribute('NotBefore')) {
360                            $nb = Utils::parseSAML2Time($scnData->getAttribute('NotBefore'));
361                            if ($nb > time() + Constants::ALLOWED_CLOCK_DRIFT) {
362                                continue;
363                            }
364                        }
365
366                        // Save NotOnOrAfter value
367                        if ($scnData->hasAttribute('NotOnOrAfter')) {
368                            $this->_validSCDNotOnOrAfter = $noa;
369                        }
370                        $anySubjectConfirmation = true;
371                        break;
372                    }
373                }
374
375                if (!$anySubjectConfirmation) {
376                    throw new ValidationError(
377                        "A valid SubjectConfirmation was not found on this Response",
378                        ValidationError::WRONG_SUBJECTCONFIRMATION
379                    );
380                }
381
382                if ($security['wantAssertionsSigned'] && !$hasSignedAssertion) {
383                    throw new ValidationError(
384                        "The Assertion of the Response is not signed and the SP requires it",
385                        ValidationError::NO_SIGNED_ASSERTION
386                    );
387                }
388
389                if ($security['wantMessagesSigned'] && !$hasSignedResponse) {
390                    throw new ValidationError(
391                        "The Message of the Response is not signed and the SP requires it",
392                        ValidationError::NO_SIGNED_MESSAGE
393                    );
394                }
395            }
396
397            // Detect case not supported
398            if ($this->encrypted) {
399                $encryptedIDNodes = Utils::query($this->decryptedDocument, '/samlp:Response/saml:Assertion/saml:Subject/saml:EncryptedID');
400                if ($encryptedIDNodes->length > 0) {
401                    throw new ValidationError(
402                        'SAML Response that contains an encrypted Assertion with encrypted nameId is not supported.',
403                        ValidationError::NOT_SUPPORTED
404                    );
405                }
406            }
407
408            if (empty($signedElements) || (!$hasSignedResponse && !$hasSignedAssertion)) {
409                throw new ValidationError(
410                    'No Signature found. SAML Response rejected',
411                    ValidationError::NO_SIGNATURE_FOUND
412                );
413            } else {
414                $cert = $idpData['x509cert'];
415                $fingerprint = $idpData['certFingerprint'];
416                $fingerprintalg = $idpData['certFingerprintAlgorithm'];
417
418                $multiCerts = null;
419                $existsMultiX509Sign = isset($idpData['x509certMulti']) && isset($idpData['x509certMulti']['signing']) && !empty($idpData['x509certMulti']['signing']);
420
421                if ($existsMultiX509Sign) {
422                    $multiCerts = $idpData['x509certMulti']['signing'];
423                }
424
425                // If find a Signature on the Response, validates it checking the original response
426                if ($hasSignedResponse && !Utils::validateSign($this->document, $cert, $fingerprint, $fingerprintalg, Utils::RESPONSE_SIGNATURE_XPATH, $multiCerts)) {
427                    throw new ValidationError(
428                        "Signature validation failed. SAML Response rejected",
429                        ValidationError::INVALID_SIGNATURE
430                    );
431                }
432
433                // If find a Signature on the Assertion (decrypted assertion if was encrypted)
434                $documentToCheckAssertion = $this->encrypted ? $this->decryptedDocument : $this->document;
435                if ($hasSignedAssertion && !Utils::validateSign($documentToCheckAssertion, $cert, $fingerprint, $fingerprintalg, Utils::ASSERTION_SIGNATURE_XPATH, $multiCerts)) {
436                    throw new ValidationError(
437                        "Signature validation failed. SAML Response rejected",
438                        ValidationError::INVALID_SIGNATURE
439                    );
440                }
441            }
442            return true;
443        } catch (Exception $e) {
444            $this->_error = $e;
445            $debug = $this->_settings->isDebugActive();
446            if ($debug) {
447                echo htmlentities($e->getMessage());
448            }
449            return false;
450        }
451    }
452
453    /**
454     * @return string|null the ID of the Response
455     */
456    public function getId()
457    {
458        $id = null;
459        if ($this->document->documentElement->hasAttribute('ID')) {
460            $id = $this->document->documentElement->getAttribute('ID');
461        }
462        return $id;
463    }
464
465    /**
466     * @return string|null the ID of the assertion in the Response
467     *
468     * @throws ValidationError
469     */
470    public function getAssertionId()
471    {
472        if (!$this->validateNumAssertions()) {
473            throw new ValidationError("SAML Response must contain 1 Assertion.", ValidationError::WRONG_NUMBER_OF_ASSERTIONS);
474        }
475        $assertionNodes = $this->_queryAssertion("");
476        $id = null;
477        if ($assertionNodes->length == 1 && $assertionNodes->item(0)->hasAttribute('ID')) {
478            $id = $assertionNodes->item(0)->getAttribute('ID');
479        }
480        return $id;
481    }
482
483    /**
484     * @return int the NotOnOrAfter value of the valid SubjectConfirmationData
485     * node if any
486     */
487    public function getAssertionNotOnOrAfter()
488    {
489        return $this->_validSCDNotOnOrAfter;
490    }
491
492    /**
493     * Checks if the Status is success
494     *
495     * @throws ValidationError If status is not success
496     */
497    public function checkStatus()
498    {
499        $status = Utils::getStatus($this->document);
500
501        if (isset($status['code']) && $status['code'] !== Constants::STATUS_SUCCESS) {
502            $explodedCode = explode(':', $status['code']);
503            $printableCode = array_pop($explodedCode);
504
505            $statusExceptionMsg = 'The status code of the Response was not Success, was '.$printableCode;
506            if (!empty($status['msg'])) {
507                $statusExceptionMsg .= ' -> '.$status['msg'];
508            }
509            throw new ValidationError(
510                $statusExceptionMsg,
511                ValidationError::STATUS_CODE_IS_NOT_SUCCESS
512            );
513        }
514    }
515
516    /**
517     * Checks that the samlp:Response/saml:Assertion/saml:Conditions element exists and is unique.
518     *
519     * @return boolean true if the Conditions element exists and is unique
520     */
521    public function checkOneCondition()
522    {
523        $entries = $this->_queryAssertion("/saml:Conditions");
524        if ($entries->length == 1) {
525            return true;
526        } else {
527            return false;
528        }
529    }
530
531    /**
532     * Checks that the samlp:Response/saml:Assertion/saml:AuthnStatement element exists and is unique.
533     *
534     * @return boolean true if the AuthnStatement element exists and is unique
535     */
536    public function checkOneAuthnStatement()
537    {
538        $entries = $this->_queryAssertion("/saml:AuthnStatement");
539        if ($entries->length == 1) {
540            return true;
541        } else {
542            return false;
543        }
544    }
545
546    /**
547     * Gets the audiences.
548     *
549     * @return array @audience The valid audiences of the response
550     */
551    public function getAudiences()
552    {
553        $audiences = array();
554
555        $entries = $this->_queryAssertion('/saml:Conditions/saml:AudienceRestriction/saml:Audience');
556        foreach ($entries as $entry) {
557            $value = trim($entry->textContent);
558            if (!empty($value)) {
559                $audiences[] = $value;
560            }
561        }
562
563        return array_unique($audiences);
564    }
565
566    /**
567     * Gets the Issuers (from Response and Assertion).
568     *
569     * @return array @issuers The issuers of the assertion/response
570     *
571     * @throws ValidationError
572     */
573    public function getIssuers()
574    {
575        $issuers = array();
576
577        $responseIssuer = Utils::query($this->document, '/samlp:Response/saml:Issuer');
578        if ($responseIssuer->length > 0) {
579            if ($responseIssuer->length == 1) {
580                $issuers[] = $responseIssuer->item(0)->textContent;
581            } else {
582                throw new ValidationError(
583                    "Issuer of the Response is multiple.",
584                    ValidationError::ISSUER_MULTIPLE_IN_RESPONSE
585                );
586            }
587        }
588
589        $assertionIssuer = $this->_queryAssertion('/saml:Issuer');
590        if ($assertionIssuer->length == 1) {
591            $issuers[] = $assertionIssuer->item(0)->textContent;
592        } else {
593            throw new ValidationError(
594                "Issuer of the Assertion not found or multiple.",
595                ValidationError::ISSUER_NOT_FOUND_IN_ASSERTION
596            );
597        }
598
599        return array_unique($issuers);
600    }
601
602    /**
603     * Gets the NameID Data provided by the SAML response from the IdP.
604     *
605     * @return array Name ID Data (Value, Format, NameQualifier, SPNameQualifier)
606     *
607     * @throws ValidationError
608     */
609    public function getNameIdData()
610    {
611        $encryptedIdDataEntries = $this->_queryAssertion('/saml:Subject/saml:EncryptedID/xenc:EncryptedData');
612
613        if ($encryptedIdDataEntries->length == 1) {
614            $encryptedData = $encryptedIdDataEntries->item(0);
615
616            $key = $this->_settings->getSPkey();
617            $seckey = new XMLSecurityKey(XMLSecurityKey::RSA_1_5, array('type'=>'private'));
618            $seckey->loadKey($key);
619
620            $nameId = Utils::decryptElement($encryptedData, $seckey);
621
622        } else {
623            $entries = $this->_queryAssertion('/saml:Subject/saml:NameID');
624            if ($entries->length == 1) {
625                $nameId = $entries->item(0);
626            }
627        }
628
629        $nameIdData = array();
630
631        if (!isset($nameId)) {
632            $security = $this->_settings->getSecurityData();
633            if ($security['wantNameId']) {
634                throw new ValidationError(
635                    "NameID not found in the assertion of the Response",
636                    ValidationError::NO_NAMEID
637                );
638            }
639        } else {
640            if ($this->_settings->isStrict() && empty($nameId->nodeValue)) {
641                throw new ValidationError(
642                    "An empty NameID value found",
643                    ValidationError::EMPTY_NAMEID
644                );
645            }
646            $nameIdData['Value'] = $nameId->nodeValue;
647
648            foreach (array('Format', 'SPNameQualifier', 'NameQualifier') as $attr) {
649                if ($nameId->hasAttribute($attr)) {
650                    if ($this->_settings->isStrict() && $attr == 'SPNameQualifier') {
651                        $spData = $this->_settings->getSPData();
652                        $spEntityId = $spData['entityId'];
653                        if ($spEntityId != $nameId->getAttribute($attr)) {
654                            throw new ValidationError(
655                                "The SPNameQualifier value mistmatch the SP entityID value.",
656                                ValidationError::SP_NAME_QUALIFIER_NAME_MISMATCH
657                            );
658                        }
659                    }
660                    $nameIdData[$attr] = $nameId->getAttribute($attr);
661                }
662            }
663        }
664
665        return $nameIdData;
666    }
667
668    /**
669     * Gets the NameID provided by the SAML response from the IdP.
670     *
671     * @return string|null Name ID Value
672     *
673     * @throws ValidationError
674     */
675    public function getNameId()
676    {
677        $nameIdvalue = null;
678        $nameIdData = $this->getNameIdData();
679        if (!empty($nameIdData) && isset($nameIdData['Value'])) {
680            $nameIdvalue = $nameIdData['Value'];
681        }
682        return $nameIdvalue;
683    }
684
685    /**
686     * Gets the NameID Format provided by the SAML response from the IdP.
687     *
688     * @return string|null Name ID Format
689     *
690     * @throws ValidationError
691     */
692    public function getNameIdFormat()
693    {
694        $nameIdFormat = null;
695        $nameIdData = $this->getNameIdData();
696        if (!empty($nameIdData) && isset($nameIdData['Format'])) {
697            $nameIdFormat = $nameIdData['Format'];
698        }
699        return $nameIdFormat;
700    }
701
702    /**
703     * Gets the NameID NameQualifier provided by the SAML response from the IdP.
704     *
705     * @return string|null Name ID NameQualifier
706     *
707     * @throws ValidationError
708     */
709    public function getNameIdNameQualifier()
710    {
711        $nameIdNameQualifier = null;
712        $nameIdData = $this->getNameIdData();
713        if (!empty($nameIdData) && isset($nameIdData['NameQualifier'])) {
714            $nameIdNameQualifier = $nameIdData['NameQualifier'];
715        }
716        return $nameIdNameQualifier;
717    }
718
719    /**
720     * Gets the NameID SP NameQualifier provided by the SAML response from the IdP.
721     *
722     * @return string|null NameID SP NameQualifier
723     *
724     * @throws ValidationError
725     */
726    public function getNameIdSPNameQualifier()
727    {
728        $nameIdSPNameQualifier = null;
729        $nameIdData = $this->getNameIdData();
730        if (!empty($nameIdData) && isset($nameIdData['SPNameQualifier'])) {
731            $nameIdSPNameQualifier = $nameIdData['SPNameQualifier'];
732        }
733        return $nameIdSPNameQualifier;
734    }
735
736    /**
737     * Gets the SessionNotOnOrAfter from the AuthnStatement.
738     * Could be used to set the local session expiration
739     *
740     * @return int|null The SessionNotOnOrAfter value
741     *
742     * @throws Exception
743     */
744    public function getSessionNotOnOrAfter()
745    {
746        $notOnOrAfter = null;
747        $entries = $this->_queryAssertion('/saml:AuthnStatement[@SessionNotOnOrAfter]');
748        if ($entries->length !== 0) {
749            $notOnOrAfter = Utils::parseSAML2Time($entries->item(0)->getAttribute('SessionNotOnOrAfter'));
750        }
751        return $notOnOrAfter;
752    }
753
754    /**
755     * Gets the SessionIndex from the AuthnStatement.
756     * Could be used to be stored in the local session in order
757     * to be used in a future Logout Request that the SP could
758     * send to the SP, to set what specific session must be deleted
759     *
760     * @return string|null The SessionIndex value
761     */
762    public function getSessionIndex()
763    {
764        $sessionIndex = null;
765        $entries = $this->_queryAssertion('/saml:AuthnStatement[@SessionIndex]');
766        if ($entries->length !== 0) {
767            $sessionIndex = $entries->item(0)->getAttribute('SessionIndex');
768        }
769        return $sessionIndex;
770    }
771
772    /**
773     * Gets the Attributes from the AttributeStatement element.
774     *
775     * @return array The attributes of the SAML Assertion
776     *
777     * @throws ValidationError
778     */
779    public function getAttributes()
780    {
781        return $this->_getAttributesByKeyName('Name');
782    }
783
784    /**
785     * Gets the Attributes from the AttributeStatement element using their FriendlyName.
786     *
787     * @return array The attributes of the SAML Assertion
788     *
789     * @throws ValidationError
790     */
791    public function getAttributesWithFriendlyName()
792    {
793        return $this->_getAttributesByKeyName('FriendlyName');
794    }
795
796    /**
797     * @param string $keyName
798     *
799     * @return array
800     *
801     * @throws ValidationError
802     */
803    private function _getAttributesByKeyName($keyName = "Name")
804    {
805        $attributes = array();
806        $entries = $this->_queryAssertion('/saml:AttributeStatement/saml:Attribute');
807        /** @var $entry DOMNode */
808        foreach ($entries as $entry) {
809            $attributeKeyNode = $entry->attributes->getNamedItem($keyName);
810            if ($attributeKeyNode === null) {
811                continue;
812            }
813            $attributeKeyName = $attributeKeyNode->nodeValue;
814            if (in_array($attributeKeyName, array_keys($attributes))) {
815                throw new ValidationError(
816                    "Found an Attribute element with duplicated ".$keyName,
817                    ValidationError::DUPLICATED_ATTRIBUTE_NAME_FOUND
818                );
819            }
820            $attributeValues = array();
821            foreach ($entry->childNodes as $childNode) {
822                $tagName = ($childNode->prefix ? $childNode->prefix.':' : '') . 'AttributeValue';
823                if ($childNode->nodeType == XML_ELEMENT_NODE && $childNode->tagName === $tagName) {
824                    $attributeValues[] = $childNode->nodeValue;
825                }
826            }
827            $attributes[$attributeKeyName] = $attributeValues;
828        }
829        return $attributes;
830    }
831
832    /**
833     * Verifies that the document only contains a single Assertion (encrypted or not).
834     *
835     * @return bool TRUE if the document passes.
836     */
837    public function validateNumAssertions()
838    {
839        $encryptedAssertionNodes = $this->document->getElementsByTagName('EncryptedAssertion');
840        $assertionNodes = $this->document->getElementsByTagName('Assertion');
841
842        $valid = $assertionNodes->length + $encryptedAssertionNodes->length == 1;
843
844        if ($this->encrypted) {
845            $assertionNodes = $this->decryptedDocument->getElementsByTagName('Assertion');
846            $valid = $valid && $assertionNodes->length == 1;
847        }
848
849        return $valid;
850    }
851
852    /**
853     * Verifies the signature nodes:
854     *   - Checks that are Response or Assertion
855     *   - Check that IDs and reference URI are unique and consistent.
856     *
857     * @return array Signed element tags
858     *
859     * @throws ValidationError
860     */
861    public function processSignedElements()
862    {
863        $signedElements = array();
864        $verifiedSeis = array();
865        $verifiedIds = array();
866
867        if ($this->encrypted) {
868            $signNodes = $this->decryptedDocument->getElementsByTagName('Signature');
869        } else {
870            $signNodes = $this->document->getElementsByTagName('Signature');
871        }
872        foreach ($signNodes as $signNode) {
873            $responseTag = '{'.Constants::NS_SAMLP.'}Response';
874            $assertionTag = '{'.Constants::NS_SAML.'}Assertion';
875
876            $signedElement = '{'.$signNode->parentNode->namespaceURI.'}'.$signNode->parentNode->localName;
877
878            if ($signedElement != $responseTag && $signedElement != $assertionTag) {
879                throw new ValidationError(
880                    "Invalid Signature Element $signedElement SAML Response rejected",
881                    ValidationError::WRONG_SIGNED_ELEMENT
882                );
883            }
884
885            // Check that reference URI matches the parent ID and no duplicate References or IDs
886            $idValue = $signNode->parentNode->getAttribute('ID');
887            if (empty($idValue)) {
888                throw new ValidationError(
889                    'Signed Element must contain an ID. SAML Response rejected',
890                    ValidationError::ID_NOT_FOUND_IN_SIGNED_ELEMENT
891                );
892            }
893
894            if (in_array($idValue, $verifiedIds)) {
895                throw new ValidationError(
896                    'Duplicated ID. SAML Response rejected',
897                    ValidationError::DUPLICATED_ID_IN_SIGNED_ELEMENTS
898                );
899            }
900            $verifiedIds[] = $idValue;
901
902            $ref = $signNode->getElementsByTagName('Reference');
903            if ($ref->length == 1) {
904                $ref = $ref->item(0);
905                $sei = $ref->getAttribute('URI');
906                if (!empty($sei)) {
907                    $sei = substr($sei, 1);
908
909                    if ($sei != $idValue) {
910                        throw new ValidationError(
911                            'Found an invalid Signed Element. SAML Response rejected',
912                            ValidationError::INVALID_SIGNED_ELEMENT
913                        );
914                    }
915
916                    if (in_array($sei, $verifiedSeis)) {
917                        throw new ValidationError(
918                            'Duplicated Reference URI. SAML Response rejected',
919                            ValidationError::DUPLICATED_REFERENCE_IN_SIGNED_ELEMENTS
920                        );
921                    }
922                    $verifiedSeis[] = $sei;
923                }
924            } else {
925                throw new ValidationError(
926                    'Unexpected number of Reference nodes found for signature. SAML Response rejected.',
927                    ValidationError::UNEXPECTED_REFERENCE
928                );
929            }
930            $signedElements[] = $signedElement;
931        }
932
933        // Check SignedElements
934        if (!empty($signedElements) && !$this->validateSignedElements($signedElements)) {
935            throw new ValidationError(
936                'Found an unexpected Signature Element. SAML Response rejected',
937                ValidationError::UNEXPECTED_SIGNED_ELEMENTS
938            );
939        }
940        return $signedElements;
941    }
942
943    /**
944     * Verifies that the document is still valid according Conditions Element.
945     *
946     * @return bool
947     *
948     * @throws Exception
949     * @throws ValidationError
950     */
951    public function validateTimestamps()
952    {
953        if ($this->encrypted) {
954            $document = $this->decryptedDocument;
955        } else {
956            $document = $this->document;
957        }
958
959        $timestampNodes = $document->getElementsByTagName('Conditions');
960        for ($i = 0; $i < $timestampNodes->length; $i++) {
961            $nbAttribute = $timestampNodes->item($i)->attributes->getNamedItem("NotBefore");
962            $naAttribute = $timestampNodes->item($i)->attributes->getNamedItem("NotOnOrAfter");
963            if ($nbAttribute && Utils::parseSAML2Time($nbAttribute->textContent) > time() + Constants::ALLOWED_CLOCK_DRIFT) {
964                throw new ValidationError(
965                    'Could not validate timestamp: not yet valid. Check system clock.',
966                    ValidationError::ASSERTION_TOO_EARLY
967                );
968            }
969            if ($naAttribute && Utils::parseSAML2Time($naAttribute->textContent) + Constants::ALLOWED_CLOCK_DRIFT <= time()) {
970                throw new ValidationError(
971                    'Could not validate timestamp: expired. Check system clock.',
972                    ValidationError::ASSERTION_EXPIRED
973                );
974            }
975        }
976        return true;
977    }
978
979    /**
980     * Verifies that the document has the expected signed nodes.
981     *
982     * @param array $signedElements Signed elements
983     *
984     * @return bool
985     *
986     * @throws ValidationError
987     */
988    public function validateSignedElements($signedElements)
989    {
990        if (count($signedElements) > 2) {
991            return false;
992        }
993
994        $responseTag = '{'.Constants::NS_SAMLP.'}Response';
995        $assertionTag = '{'.Constants::NS_SAML.'}Assertion';
996
997        $ocurrence = array_count_values($signedElements);
998        if ((in_array($responseTag, $signedElements) && $ocurrence[$responseTag] > 1)
999            || (in_array($assertionTag, $signedElements) && $ocurrence[$assertionTag] > 1)
1000            || !in_array($responseTag, $signedElements) && !in_array($assertionTag, $signedElements)
1001        ) {
1002            return false;
1003        }
1004
1005        // Check that the signed elements found here, are the ones that will be verified
1006        // by Utils->validateSign()
1007        if (in_array($responseTag, $signedElements)) {
1008            $expectedSignatureNodes = Utils::query($this->document, Utils::RESPONSE_SIGNATURE_XPATH);
1009            if ($expectedSignatureNodes->length != 1) {
1010                throw new ValidationError(
1011                    "Unexpected number of Response signatures found. SAML Response rejected.",
1012                    ValidationError::WRONG_NUMBER_OF_SIGNATURES_IN_RESPONSE
1013                );
1014            }
1015        }
1016
1017        if (in_array($assertionTag, $signedElements)) {
1018            $expectedSignatureNodes = $this->_query(Utils::ASSERTION_SIGNATURE_XPATH);
1019            if ($expectedSignatureNodes->length != 1) {
1020                throw new ValidationError(
1021                    "Unexpected number of Assertion signatures found. SAML Response rejected.",
1022                    ValidationError::WRONG_NUMBER_OF_SIGNATURES_IN_ASSERTION
1023                );
1024            }
1025        }
1026
1027        return true;
1028    }
1029
1030    /**
1031     * Extracts a node from the DOMDocument (Assertion).
1032     *
1033     * @param string $assertionXpath Xpath Expression
1034     *
1035     * @return DOMNodeList The queried node
1036     */
1037    protected function _queryAssertion($assertionXpath)
1038    {
1039        if ($this->encrypted) {
1040            $xpath = new DOMXPath($this->decryptedDocument);
1041        } else {
1042            $xpath = new DOMXPath($this->document);
1043        }
1044
1045        $xpath->registerNamespace('samlp', Constants::NS_SAMLP);
1046        $xpath->registerNamespace('saml', Constants::NS_SAML);
1047        $xpath->registerNamespace('ds', Constants::NS_DS);
1048        $xpath->registerNamespace('xenc', Constants::NS_XENC);
1049
1050        $assertionNode = '/samlp:Response/saml:Assertion';
1051        $signatureQuery = $assertionNode . '/ds:Signature/ds:SignedInfo/ds:Reference';
1052        $assertionReferenceNode = $xpath->query($signatureQuery)->item(0);
1053        if (!$assertionReferenceNode) {
1054            // is the response signed as a whole?
1055            $signatureQuery = '/samlp:Response/ds:Signature/ds:SignedInfo/ds:Reference';
1056            $responseReferenceNode = $xpath->query($signatureQuery)->item(0);
1057            if ($responseReferenceNode) {
1058                $uri = $responseReferenceNode->attributes->getNamedItem('URI')->nodeValue;
1059                if (empty($uri)) {
1060                    $id = $responseReferenceNode->parentNode->parentNode->parentNode->attributes->getNamedItem('ID')->nodeValue;
1061                } else {
1062                    $id = substr($responseReferenceNode->attributes->getNamedItem('URI')->nodeValue, 1);
1063                }
1064                $nameQuery = "/samlp:Response[@ID='$id']/saml:Assertion" . $assertionXpath;
1065            } else {
1066                $nameQuery = "/samlp:Response/saml:Assertion" . $assertionXpath;
1067            }
1068        } else {
1069            $uri = $assertionReferenceNode->attributes->getNamedItem('URI')->nodeValue;
1070            if (empty($uri)) {
1071                $id = $assertionReferenceNode->parentNode->parentNode->parentNode->attributes->getNamedItem('ID')->nodeValue;
1072            } else {
1073                $id = substr($assertionReferenceNode->attributes->getNamedItem('URI')->nodeValue, 1);
1074            }
1075            $nameQuery = $assertionNode."[@ID='$id']" . $assertionXpath;
1076        }
1077
1078        return $xpath->query($nameQuery);
1079    }
1080
1081    /**
1082     * Extracts nodes that match the query from the DOMDocument (Response Menssage)
1083     *
1084     * @param string $query Xpath Expression
1085     *
1086     * @return DOMNodeList The queried nodes
1087     */
1088    private function _query($query)
1089    {
1090        if ($this->encrypted) {
1091            return Utils::query($this->decryptedDocument, $query);
1092        } else {
1093            return Utils::query($this->document, $query);
1094        }
1095    }
1096
1097    /**
1098     * Decrypts the Assertion (DOMDocument)
1099     *
1100     * @param \DomNode $dom DomDocument
1101     *
1102     * @return DOMDocument Decrypted Assertion
1103     *
1104     * @throws Exception
1105     * @throws ValidationError
1106     */
1107    protected function decryptAssertion(\DomNode $dom)
1108    {
1109        $pem = $this->_settings->getSPkey();
1110
1111        if (empty($pem)) {
1112            throw new Error(
1113                "No private key available, check settings",
1114                Error::PRIVATE_KEY_NOT_FOUND
1115            );
1116        }
1117
1118        $objenc = new XMLSecEnc();
1119        $encData = $objenc->locateEncryptedData($dom);
1120        if (!$encData) {
1121            throw new ValidationError(
1122                "Cannot locate encrypted assertion",
1123                ValidationError::MISSING_ENCRYPTED_ELEMENT
1124            );
1125        }
1126
1127        $objenc->setNode($encData);
1128        $objenc->type = $encData->getAttribute("Type");
1129        if (!$objKey = $objenc->locateKey()) {
1130            throw new ValidationError(
1131                "Unknown algorithm",
1132                ValidationError::KEY_ALGORITHM_ERROR
1133            );
1134        }
1135
1136        $key = null;
1137        if ($objKeyInfo = $objenc->locateKeyInfo($objKey)) {
1138            if ($objKeyInfo->isEncrypted) {
1139                $objencKey = $objKeyInfo->encryptedCtx;
1140                $objKeyInfo->loadKey($pem, false, false);
1141                $key = $objencKey->decryptKey($objKeyInfo);
1142            } else {
1143                // symmetric encryption key support
1144                $objKeyInfo->loadKey($pem, false, false);
1145            }
1146        }
1147
1148        if (empty($objKey->key)) {
1149            $objKey->loadKey($key);
1150        }
1151
1152        $decryptedXML = $objenc->decryptNode($objKey, false);
1153        $decrypted = new DOMDocument();
1154        $check = Utils::loadXML($decrypted, $decryptedXML);
1155        if ($check === false) {
1156            throw new Exception('Error: string from decrypted assertion could not be loaded into a XML document');
1157        }
1158        if ($encData->parentNode instanceof DOMDocument) {
1159            return $decrypted;
1160        } else {
1161            $decrypted = $decrypted->documentElement;
1162            $encryptedAssertion = $encData->parentNode;
1163            $container = $encryptedAssertion->parentNode;
1164
1165            // Fix possible issue with saml namespace
1166            if (!$decrypted->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:saml')
1167                && !$decrypted->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:saml2')
1168                && !$decrypted->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns')
1169                && !$container->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:saml')
1170                && !$container->hasAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:saml2')
1171            ) {
1172                if (strpos($encryptedAssertion->tagName, 'saml2:') !== false) {
1173                    $ns = 'xmlns:saml2';
1174                } else if (strpos($encryptedAssertion->tagName, 'saml:') !== false) {
1175                    $ns = 'xmlns:saml';
1176                } else {
1177                    $ns = 'xmlns';
1178                }
1179                $decrypted->setAttributeNS('http://www.w3.org/2000/xmlns/', $ns, Constants::NS_SAML);
1180            }
1181
1182            Utils::treeCopyReplace($encryptedAssertion, $decrypted);
1183
1184            // Rebuild the DOM will fix issues with namespaces as well
1185            $dom = new DOMDocument();
1186            return Utils::loadXML($dom, $container->ownerDocument->saveXML());
1187        }
1188    }
1189
1190    /**
1191     * After execute a validation process, if fails this method returns the cause
1192     *
1193     * @return Exception|null Cause
1194     */
1195    public function getErrorException()
1196    {
1197        return $this->_error;
1198    }
1199
1200    /**
1201     * After execute a validation process, if fails this method returns the cause
1202     *
1203     * @return null|string Error reason
1204     */
1205    public function getError()
1206    {
1207        $errorMsg = null;
1208        if (isset($this->_error)) {
1209            $errorMsg = htmlentities($this->_error->getMessage());
1210        }
1211        return $errorMsg;
1212    }
1213
1214    /**
1215     * Returns the SAML Response document (If contains an encrypted assertion, decrypts it)
1216     *
1217     * @return DomDocument SAML Response
1218     */
1219    public function getXMLDocument()
1220    {
1221        if ($this->encrypted) {
1222            return $this->decryptedDocument;
1223        } else {
1224            return $this->document;
1225        }
1226    }
1227}
1228