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