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