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; 19 20use DOMDocument; 21use Exception; 22 23/** 24 * SAML 2 Logout Request 25 */ 26class LogoutRequest 27{ 28 /** 29 * Contains the ID of the Logout Request 30 * 31 * @var string 32 */ 33 public $id; 34 35 /** 36 * Object that represents the setting info 37 * 38 * @var Settings 39 */ 40 protected $_settings; 41 42 /** 43 * SAML Logout Request 44 * 45 * @var string 46 */ 47 protected $_logoutRequest; 48 49 /** 50 * After execute a validation process, this var contains the cause 51 * 52 * @var Exception 53 */ 54 private $_error; 55 56 /** 57 * Constructs the Logout Request object. 58 * 59 * @param Settings $settings Settings 60 * @param string|null $request A UUEncoded Logout Request. 61 * @param string|null $nameId The NameID that will be set in the LogoutRequest. 62 * @param string|null $sessionIndex The SessionIndex (taken from the SAML Response in the SSO process). 63 * @param string|null $nameIdFormat The NameID Format will be set in the LogoutRequest. 64 * @param string|null $nameIdNameQualifier The NameID NameQualifier will be set in the LogoutRequest. 65 * @param string|null $nameIdSPNameQualifier The NameID SP NameQualifier will be set in the LogoutRequest. 66 */ 67 public function __construct(\OneLogin\Saml2\Settings $settings, $request = null, $nameId = null, $sessionIndex = null, $nameIdFormat = null, $nameIdNameQualifier = null, $nameIdSPNameQualifier = null) 68 { 69 $this->_settings = $settings; 70 71 $baseURL = $this->_settings->getBaseURL(); 72 if (!empty($baseURL)) { 73 Utils::setBaseURL($baseURL); 74 } 75 76 if (!isset($request) || empty($request)) { 77 $spData = $this->_settings->getSPData(); 78 $idpData = $this->_settings->getIdPData(); 79 $security = $this->_settings->getSecurityData(); 80 81 $id = Utils::generateUniqueID(); 82 $this->id = $id; 83 84 $issueInstant = Utils::parseTime2SAML(time()); 85 86 $cert = null; 87 if (isset($security['nameIdEncrypted']) && $security['nameIdEncrypted']) { 88 $existsMultiX509Enc = isset($idpData['x509certMulti']) && isset($idpData['x509certMulti']['encryption']) && !empty($idpData['x509certMulti']['encryption']); 89 90 if ($existsMultiX509Enc) { 91 $cert = $idpData['x509certMulti']['encryption'][0]; 92 } else { 93 $cert = $idpData['x509cert']; 94 } 95 } 96 97 if (!empty($nameId)) { 98 if (empty($nameIdFormat) 99 && $spData['NameIDFormat'] != Constants::NAMEID_UNSPECIFIED) { 100 $nameIdFormat = $spData['NameIDFormat']; 101 } 102 } else { 103 $nameId = $idpData['entityId']; 104 $nameIdFormat = Constants::NAMEID_ENTITY; 105 } 106 107 /* From saml-core-2.0-os 8.3.6, when the entity Format is used: 108 "The NameQualifier, SPNameQualifier, and SPProvidedID attributes MUST be omitted. 109 */ 110 if (!empty($nameIdFormat) && $nameIdFormat == Constants::NAMEID_ENTITY) { 111 $nameIdNameQualifier = null; 112 $nameIdSPNameQualifier = null; 113 } 114 115 // NameID Format UNSPECIFIED omitted 116 if (!empty($nameIdFormat) && $nameIdFormat == Constants::NAMEID_UNSPECIFIED) { 117 $nameIdFormat = null; 118 } 119 120 $nameIdObj = Utils::generateNameId( 121 $nameId, 122 $nameIdSPNameQualifier, 123 $nameIdFormat, 124 $cert, 125 $nameIdNameQualifier 126 ); 127 128 $sessionIndexStr = isset($sessionIndex) ? "<samlp:SessionIndex>{$sessionIndex}</samlp:SessionIndex>" : ""; 129 130 $spEntityId = htmlspecialchars($spData['entityId'], ENT_QUOTES); 131 $logoutRequest = <<<LOGOUTREQUEST 132<samlp:LogoutRequest 133 xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" 134 xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" 135 ID="{$id}" 136 Version="2.0" 137 IssueInstant="{$issueInstant}" 138 Destination="{$idpData['singleLogoutService']['url']}"> 139 <saml:Issuer>{$spEntityId}</saml:Issuer> 140 {$nameIdObj} 141 {$sessionIndexStr} 142</samlp:LogoutRequest> 143LOGOUTREQUEST; 144 } else { 145 $decoded = base64_decode($request); 146 // We try to inflate 147 $inflated = @gzinflate($decoded); 148 if ($inflated != false) { 149 $logoutRequest = $inflated; 150 } else { 151 $logoutRequest = $decoded; 152 } 153 $this->id = static::getID($logoutRequest); 154 } 155 $this->_logoutRequest = $logoutRequest; 156 } 157 158 /** 159 * Returns the Logout Request defated, base64encoded, unsigned 160 * 161 * @param bool|null $deflate Whether or not we should 'gzdeflate' the request body before we return it. 162 * 163 * @return string Deflated base64 encoded Logout Request 164 */ 165 public function getRequest($deflate = null) 166 { 167 $subject = $this->_logoutRequest; 168 169 if (is_null($deflate)) { 170 $deflate = $this->_settings->shouldCompressRequests(); 171 } 172 173 if ($deflate) { 174 $subject = gzdeflate($this->_logoutRequest); 175 } 176 177 return base64_encode($subject); 178 } 179 180 /** 181 * Returns the ID of the Logout Request. 182 * 183 * @param string|DOMDocument $request Logout Request Message 184 * 185 * @return string ID 186 * 187 * @throws Error 188 */ 189 public static function getID($request) 190 { 191 if ($request instanceof DOMDocument) { 192 $dom = $request; 193 } else { 194 $dom = new DOMDocument(); 195 $dom = Utils::loadXML($dom, $request); 196 } 197 198 199 if (false === $dom) { 200 throw new Error( 201 "LogoutRequest could not be processed", 202 Error::SAML_LOGOUTREQUEST_INVALID 203 ); 204 } 205 206 $id = $dom->documentElement->getAttribute('ID'); 207 return $id; 208 } 209 210 /** 211 * Gets the NameID Data of the the Logout Request. 212 * 213 * @param string|DOMDocument $request Logout Request Message 214 * @param string|null $key The SP key 215 * 216 * @return array Name ID Data (Value, Format, NameQualifier, SPNameQualifier) 217 * 218 * @throws Error 219 * @throws Exception 220 * @throws ValidationError 221 */ 222 public static function getNameIdData($request, $key = null) 223 { 224 if ($request instanceof DOMDocument) { 225 $dom = $request; 226 } else { 227 $dom = new DOMDocument(); 228 $dom = Utils::loadXML($dom, $request); 229 } 230 231 $encryptedEntries = Utils::query($dom, '/samlp:LogoutRequest/saml:EncryptedID'); 232 233 if ($encryptedEntries->length == 1) { 234 $encryptedDataNodes = $encryptedEntries->item(0)->getElementsByTagName('EncryptedData'); 235 $encryptedData = $encryptedDataNodes->item(0); 236 237 if (empty($key)) { 238 throw new Error( 239 "Private Key is required in order to decrypt the NameID, check settings", 240 Error::PRIVATE_KEY_NOT_FOUND 241 ); 242 } 243 244 $seckey = new XMLSecurityKey(XMLSecurityKey::RSA_1_5, array('type'=>'private')); 245 $seckey->loadKey($key); 246 247 $nameId = Utils::decryptElement($encryptedData, $seckey); 248 249 } else { 250 $entries = Utils::query($dom, '/samlp:LogoutRequest/saml:NameID'); 251 if ($entries->length == 1) { 252 $nameId = $entries->item(0); 253 } 254 } 255 256 if (!isset($nameId)) { 257 throw new ValidationError( 258 "NameID not found in the Logout Request", 259 ValidationError::NO_NAMEID 260 ); 261 } 262 263 $nameIdData = array(); 264 $nameIdData['Value'] = $nameId->nodeValue; 265 foreach (array('Format', 'SPNameQualifier', 'NameQualifier') as $attr) { 266 if ($nameId->hasAttribute($attr)) { 267 $nameIdData[$attr] = $nameId->getAttribute($attr); 268 } 269 } 270 271 return $nameIdData; 272 } 273 274 /** 275 * Gets the NameID of the Logout Request. 276 * 277 * @param string|DOMDocument $request Logout Request Message 278 * @param string|null $key The SP key 279 * 280 * @return string Name ID Value 281 * 282 * @throws Error 283 * @throws Exception 284 * @throws ValidationError 285 */ 286 public static function getNameId($request, $key = null) 287 { 288 $nameId = self::getNameIdData($request, $key); 289 return $nameId['Value']; 290 } 291 292 /** 293 * Gets the Issuer of the Logout Request. 294 * 295 * @param string|DOMDocument $request Logout Request Message 296 * 297 * @return string|null $issuer The Issuer 298 * 299 * @throws Exception 300 */ 301 public static function getIssuer($request) 302 { 303 if ($request instanceof DOMDocument) { 304 $dom = $request; 305 } else { 306 $dom = new DOMDocument(); 307 $dom = Utils::loadXML($dom, $request); 308 } 309 310 $issuer = null; 311 $issuerNodes = Utils::query($dom, '/samlp:LogoutRequest/saml:Issuer'); 312 if ($issuerNodes->length == 1) { 313 $issuer = $issuerNodes->item(0)->textContent; 314 } 315 return $issuer; 316 } 317 318 /** 319 * Gets the SessionIndexes from the Logout Request. 320 * Notice: Our Constructor only support 1 SessionIndex but this parser 321 * extracts an array of all the SessionIndex found on a 322 * Logout Request, that could be many. 323 * 324 * @param string|DOMDocument $request Logout Request Message 325 * 326 * @return array The SessionIndex value 327 * 328 * @throws Exception 329 */ 330 public static function getSessionIndexes($request) 331 { 332 if ($request instanceof DOMDocument) { 333 $dom = $request; 334 } else { 335 $dom = new DOMDocument(); 336 $dom = Utils::loadXML($dom, $request); 337 } 338 339 $sessionIndexes = array(); 340 $sessionIndexNodes = Utils::query($dom, '/samlp:LogoutRequest/samlp:SessionIndex'); 341 foreach ($sessionIndexNodes as $sessionIndexNode) { 342 $sessionIndexes[] = $sessionIndexNode->textContent; 343 } 344 return $sessionIndexes; 345 } 346 347 /** 348 * Checks if the Logout Request recieved is valid. 349 * 350 * @param bool $retrieveParametersFromServer True if we want to use parameters from $_SERVER to validate the signature 351 * 352 * @return bool If the Logout Request is or not valid 353 * 354 * @throws Exception 355 * @throws ValidationError 356 */ 357 public function isValid($retrieveParametersFromServer = false) 358 { 359 $this->_error = null; 360 try { 361 $dom = new DOMDocument(); 362 $dom = Utils::loadXML($dom, $this->_logoutRequest); 363 364 $idpData = $this->_settings->getIdPData(); 365 $idPEntityId = $idpData['entityId']; 366 367 if ($this->_settings->isStrict()) { 368 $security = $this->_settings->getSecurityData(); 369 370 if ($security['wantXMLValidation']) { 371 $res = Utils::validateXML($dom, 'saml-schema-protocol-2.0.xsd', $this->_settings->isDebugActive(), $this->_settings->getSchemasPath()); 372 if (!$res instanceof DOMDocument) { 373 throw new ValidationError( 374 "Invalid SAML Logout Request. Not match the saml-schema-protocol-2.0.xsd", 375 ValidationError::INVALID_XML_FORMAT 376 ); 377 } 378 } 379 380 $currentURL = Utils::getSelfRoutedURLNoQuery(); 381 382 // Check NotOnOrAfter 383 if ($dom->documentElement->hasAttribute('NotOnOrAfter')) { 384 $na = Utils::parseSAML2Time($dom->documentElement->getAttribute('NotOnOrAfter')); 385 if ($na <= time()) { 386 throw new ValidationError( 387 "Could not validate timestamp: expired. Check system clock.", 388 ValidationError::RESPONSE_EXPIRED 389 ); 390 } 391 } 392 393 // Check destination 394 if ($dom->documentElement->hasAttribute('Destination')) { 395 $destination = $dom->documentElement->getAttribute('Destination'); 396 if (empty($destination)) { 397 if (!$security['relaxDestinationValidation']) { 398 throw new ValidationError( 399 "The LogoutRequest has an empty Destination value", 400 ValidationError::EMPTY_DESTINATION 401 ); 402 } 403 } else { 404 $urlComparisonLength = $security['destinationStrictlyMatches'] ? strlen($destination) : strlen($currentURL); 405 if (strncmp($destination, $currentURL, $urlComparisonLength) !== 0) { 406 $currentURLNoRouted = Utils::getSelfURLNoQuery(); 407 $urlComparisonLength = $security['destinationStrictlyMatches'] ? strlen($destination) : strlen($currentURLNoRouted); 408 if (strncmp($destination, $currentURLNoRouted, $urlComparisonLength) !== 0) { 409 throw new ValidationError( 410 "The LogoutRequest was received at $currentURL instead of $destination", 411 ValidationError::WRONG_DESTINATION 412 ); 413 } 414 } 415 } 416 } 417 418 $nameId = static::getNameId($dom, $this->_settings->getSPkey()); 419 420 // Check issuer 421 $issuer = static::getIssuer($dom); 422 if (!empty($issuer) && $issuer != $idPEntityId) { 423 throw new ValidationError( 424 "Invalid issuer in the Logout Request", 425 ValidationError::WRONG_ISSUER 426 ); 427 } 428 429 if ($security['wantMessagesSigned'] && !isset($_GET['Signature'])) { 430 throw new ValidationError( 431 "The Message of the Logout Request is not signed and the SP require it", 432 ValidationError::NO_SIGNED_MESSAGE 433 ); 434 } 435 } 436 437 if (isset($_GET['Signature'])) { 438 $signatureValid = Utils::validateBinarySign("SAMLRequest", $_GET, $idpData, $retrieveParametersFromServer); 439 if (!$signatureValid) { 440 throw new ValidationError( 441 "Signature validation failed. Logout Request rejected", 442 ValidationError::INVALID_SIGNATURE 443 ); 444 } 445 } 446 447 return true; 448 } catch (Exception $e) { 449 $this->_error = $e; 450 $debug = $this->_settings->isDebugActive(); 451 if ($debug) { 452 echo htmlentities($this->_error->getMessage()); 453 } 454 return false; 455 } 456 } 457 458 /** 459 * After execute a validation process, if fails this method returns the Exception of the cause 460 * 461 * @return Exception Cause 462 */ 463 public function getErrorException() 464 { 465 return $this->_error; 466 } 467 468 /** 469 * After execute a validation process, if fails this method returns the cause 470 * 471 * @return null|string Error reason 472 */ 473 public function getError() 474 { 475 $errorMsg = null; 476 if (isset($this->_error)) { 477 $errorMsg = htmlentities($this->_error->getMessage()); 478 } 479 return $errorMsg; 480 } 481 482 /** 483 * Returns the XML that will be sent as part of the request 484 * or that was received at the SP 485 * 486 * @return string 487 */ 488 public function getXML() 489 { 490 return $this->_logoutRequest; 491 } 492} 493