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 DOMDocument;
19use DOMNodeList;
20use Exception;
21
22/**
23 * SAML 2 Logout Response
24 */
25class LogoutResponse
26{
27    /**
28     * Contains the ID of the Logout Response
29     *
30     * @var string
31     */
32    public $id;
33
34    /**
35     * Object that represents the setting info
36     *
37     * @var Settings
38     */
39    protected $_settings;
40
41    /**
42     * The decoded, unprocessed XML response provided to the constructor.
43     *
44     * @var string|null
45     */
46    protected $_logoutResponse;
47
48    /**
49     * A DOMDocument class loaded from the SAML LogoutResponse.
50     *
51     * @var DOMDocument
52     */
53    public $document;
54
55    /**
56     * After execute a validation process, if it fails, this var contains the cause
57     *
58     * @var Exception|null
59     */
60    private $_error;
61
62    /**
63     * Constructs a Logout Response object (Initialize params from settings and if provided
64     * load the Logout Response.
65     *
66     * @param Settings $settings Settings.
67     * @param string|null             $response An UUEncoded SAML Logout response from the IdP.
68     *
69     * @throws Error
70     * @throws Exception
71     */
72    public function __construct(\OneLogin\Saml2\Settings $settings, $response = null)
73    {
74        $this->_settings = $settings;
75
76        $baseURL = $this->_settings->getBaseURL();
77        if (!empty($baseURL)) {
78            Utils::setBaseURL($baseURL);
79        }
80
81        if ($response) {
82            $decoded = base64_decode($response);
83            $inflated = @gzinflate($decoded);
84            if ($inflated != false) {
85                $this->_logoutResponse = $inflated;
86            } else {
87                $this->_logoutResponse = $decoded;
88            }
89            $this->document = new DOMDocument();
90            $this->document = Utils::loadXML($this->document, $this->_logoutResponse);
91
92            if (false === $this->document) {
93                throw new Error(
94                    "LogoutResponse could not be processed",
95                    Error::SAML_LOGOUTRESPONSE_INVALID
96                );
97            }
98
99            if ($this->document->documentElement->hasAttribute('ID')) {
100                $this->id = $this->document->documentElement->getAttribute('ID');
101            }
102        }
103    }
104
105    /**
106     * Gets the Issuer of the Logout Response.
107     *
108     * @return string|null $issuer The Issuer
109     */
110    public function getIssuer()
111    {
112        $issuer = null;
113        $issuerNodes = $this->_query('/samlp:LogoutResponse/saml:Issuer');
114        if ($issuerNodes->length == 1) {
115            $issuer = $issuerNodes->item(0)->textContent;
116        }
117        return $issuer;
118    }
119
120    /**
121     * Gets the Status of the Logout Response.
122     *
123     * @return string|null The Status
124     */
125    public function getStatus()
126    {
127        $entries = $this->_query('/samlp:LogoutResponse/samlp:Status/samlp:StatusCode');
128        if ($entries->length != 1) {
129            return null;
130        }
131        $status = $entries->item(0)->getAttribute('Value');
132        return $status;
133    }
134
135    /**
136     * Determines if the SAML LogoutResponse is valid
137     *
138     * @param string|null $requestId                    The ID of the LogoutRequest sent by this SP to the IdP
139     * @param bool        $retrieveParametersFromServer True if we want to use parameters from $_SERVER to validate the signature
140     *
141     * @return bool Returns if the SAML LogoutResponse is or not valid
142     *
143     * @throws ValidationError
144     */
145    public function isValid($requestId = null, $retrieveParametersFromServer = false)
146    {
147        $this->_error = null;
148        try {
149            $idpData = $this->_settings->getIdPData();
150            $idPEntityId = $idpData['entityId'];
151
152            if ($this->_settings->isStrict()) {
153                $security = $this->_settings->getSecurityData();
154
155                if ($security['wantXMLValidation']) {
156                    $res = Utils::validateXML($this->document, 'saml-schema-protocol-2.0.xsd', $this->_settings->isDebugActive(), $this->_settings->getSchemasPath());
157                    if (!$res instanceof DOMDocument) {
158                        throw new ValidationError(
159                            "Invalid SAML Logout Response. Not match the saml-schema-protocol-2.0.xsd",
160                            ValidationError::INVALID_XML_FORMAT
161                        );
162                    }
163                }
164
165                // Check if the InResponseTo of the Logout Response matchs the ID of the Logout Request (requestId) if provided
166                if (isset($requestId) && $this->document->documentElement->hasAttribute('InResponseTo')) {
167                    $inResponseTo = $this->document->documentElement->getAttribute('InResponseTo');
168                    if ($requestId != $inResponseTo) {
169                        throw new ValidationError(
170                            "The InResponseTo of the Logout Response: $inResponseTo, does not match the ID of the Logout request sent by the SP: $requestId",
171                            ValidationError::WRONG_INRESPONSETO
172                        );
173                    }
174                }
175
176                // Check issuer
177                $issuer = $this->getIssuer();
178                if (!empty($issuer) && $issuer != $idPEntityId) {
179                    throw new ValidationError(
180                        "Invalid issuer in the Logout Response",
181                        ValidationError::WRONG_ISSUER
182                    );
183                }
184
185                $currentURL = Utils::getSelfRoutedURLNoQuery();
186
187                if ($this->document->documentElement->hasAttribute('Destination')) {
188                    $destination = $this->document->documentElement->getAttribute('Destination');
189                    if (empty($destination)) {
190                        if (!$security['relaxDestinationValidation']) {
191                            throw new ValidationError(
192                                "The LogoutResponse has an empty Destination value",
193                                ValidationError::EMPTY_DESTINATION
194                            );
195                        }
196                    } else {
197                        $urlComparisonLength = $security['destinationStrictlyMatches'] ? strlen($destination) : strlen($currentURL);
198                        if (strncmp($destination, $currentURL, $urlComparisonLength) !== 0) {
199                            $currentURLNoRouted = Utils::getSelfURLNoQuery();
200                            $urlComparisonLength = $security['destinationStrictlyMatches'] ? strlen($destination) : strlen($currentURLNoRouted);
201                            if (strncmp($destination, $currentURLNoRouted, $urlComparisonLength) !== 0) {
202                                throw new ValidationError(
203                                    "The LogoutResponse was received at $currentURL instead of $destination",
204                                    ValidationError::WRONG_DESTINATION
205                                );
206                            }
207                        }
208                    }
209                }
210
211                if ($security['wantMessagesSigned'] && !isset($_GET['Signature'])) {
212                    throw new ValidationError(
213                        "The Message of the Logout Response is not signed and the SP requires it",
214                        ValidationError::NO_SIGNED_MESSAGE
215                    );
216                }
217            }
218
219            if (isset($_GET['Signature'])) {
220                $signatureValid = Utils::validateBinarySign("SAMLResponse", $_GET, $idpData, $retrieveParametersFromServer);
221                if (!$signatureValid) {
222                    throw new ValidationError(
223                        "Signature validation failed. Logout Response rejected",
224                        ValidationError::INVALID_SIGNATURE
225                    );
226                }
227            }
228            return true;
229        } catch (Exception $e) {
230            $this->_error = $e;
231            $debug = $this->_settings->isDebugActive();
232            if ($debug) {
233                echo htmlentities($this->_error->getMessage());
234            }
235            return false;
236        }
237    }
238
239    /**
240     * Extracts a node from the DOMDocument (Logout Response Menssage)
241     *
242     * @param string $query Xpath Expression
243     *
244     * @return DOMNodeList The queried node
245     */
246    private function _query($query)
247    {
248        return Utils::query($this->document, $query);
249
250    }
251
252    /**
253     * Generates a Logout Response object.
254     *
255     * @param string $inResponseTo InResponseTo value for the Logout Response.
256     */
257    public function build($inResponseTo)
258    {
259
260        $spData = $this->_settings->getSPData();
261        $idpData = $this->_settings->getIdPData();
262
263        $this->id = Utils::generateUniqueID();
264        $issueInstant = Utils::parseTime2SAML(time());
265
266        $spEntityId = htmlspecialchars($spData['entityId'], ENT_QUOTES);
267        $logoutResponse = <<<LOGOUTRESPONSE
268<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
269                  xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
270                  ID="{$this->id}"
271                  Version="2.0"
272                  IssueInstant="{$issueInstant}"
273                  Destination="{$idpData['singleLogoutService']['url']}"
274                  InResponseTo="{$inResponseTo}"
275                  >
276    <saml:Issuer>{$spEntityId}</saml:Issuer>
277    <samlp:Status>
278        <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
279    </samlp:Status>
280</samlp:LogoutResponse>
281LOGOUTRESPONSE;
282        $this->_logoutResponse = $logoutResponse;
283    }
284
285    /**
286     * Returns a Logout Response object.
287     *
288     * @param bool|null $deflate Whether or not we should 'gzdeflate' the response body before we return it.
289     *
290     * @return string Logout Response deflated and base64 encoded
291     */
292    public function getResponse($deflate = null)
293    {
294        $logoutResponse = $this->_logoutResponse;
295
296        if (is_null($deflate)) {
297            $deflate = $this->_settings->shouldCompressResponses();
298        }
299
300        if ($deflate) {
301            $logoutResponse = gzdeflate($this->_logoutResponse);
302        }
303        return base64_encode($logoutResponse);
304    }
305
306    /**
307     * After execute a validation process, if fails this method returns the cause.
308     *
309     * @return Exception|null Cause
310     */
311    public function getErrorException()
312    {
313        return $this->_error;
314    }
315
316    /**
317     * After execute a validation process, if fails this method returns the cause
318     *
319     * @return null|string Error reason
320     */
321    public function getError()
322    {
323        $errorMsg = null;
324        if (isset($this->_error)) {
325            $errorMsg = htmlentities($this->_error->getMessage());
326        }
327        return $errorMsg;
328    }
329
330    /**
331     * @return string the ID of the Response
332     */
333    public function getId()
334    {
335        return $this->id;
336    }
337
338    /**
339     * Returns the XML that will be sent as part of the response
340     * or that was received at the SP
341     *
342     * @return string|null
343     */
344    public function getXML()
345    {
346        return $this->_logoutResponse;
347    }
348}
349