1<?php
2
3/**
4 * Licensed to Jasig under one or more contributor license
5 * agreements. See the NOTICE file distributed with this work for
6 * additional information regarding copyright ownership.
7 *
8 * Jasig licenses this file to you under the Apache License,
9 * Version 2.0 (the "License"); you may not use this file except in
10 * compliance with the License. You may obtain a copy of the License at:
11 *
12 * http://www.apache.org/licenses/LICENSE-2.0
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the License is distributed on an "AS IS" BASIS,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 * See the License for the specific language governing permissions and
18 * limitations under the License.
19 *
20 * PHP Version 7
21 *
22 * @file     CAS/CookieJar.php
23 * @category Authentication
24 * @package  PhpCAS
25 * @author   Adam Franco <afranco@middlebury.edu>
26 * @license  http://www.apache.org/licenses/LICENSE-2.0  Apache License 2.0
27 * @link     https://wiki.jasig.org/display/CASC/phpCAS
28 */
29
30/**
31 * This class provides access to service cookies and handles parsing of response
32 * headers to pull out cookie values.
33 *
34 * @class    CAS_CookieJar
35 * @category Authentication
36 * @package  PhpCAS
37 * @author   Adam Franco <afranco@middlebury.edu>
38 * @license  http://www.apache.org/licenses/LICENSE-2.0  Apache License 2.0
39 * @link     https://wiki.jasig.org/display/CASC/phpCAS
40 */
41class CAS_CookieJar
42{
43
44    private $_cookies;
45
46    /**
47     * Create a new cookie jar by passing it a reference to an array in which it
48     * should store cookies.
49     *
50     * @param array &$storageArray Array to store cookies
51     *
52     * @return void
53     */
54    public function __construct (array &$storageArray)
55    {
56        $this->_cookies =& $storageArray;
57    }
58
59    /**
60     * Store cookies for a web service request.
61     * Cookie storage is based on RFC 2965: http://www.ietf.org/rfc/rfc2965.txt
62     *
63     * @param string $request_url      The URL that generated the response headers.
64     * @param array  $response_headers An array of the HTTP response header strings.
65     *
66     * @return void
67     *
68     * @access private
69     */
70    public function storeCookies ($request_url, $response_headers)
71    {
72        $urlParts = parse_url($request_url);
73        $defaultDomain = $urlParts['host'];
74
75        $cookies = $this->parseCookieHeaders($response_headers, $defaultDomain);
76
77        foreach ($cookies as $cookie) {
78            // Enforce the same-origin policy by verifying that the cookie
79            // would match the url that is setting it
80            if (!$this->cookieMatchesTarget($cookie, $urlParts)) {
81                continue;
82            }
83
84            // store the cookie
85            $this->storeCookie($cookie);
86
87            phpCAS::trace($cookie['name'].' -> '.$cookie['value']);
88        }
89    }
90
91    /**
92     * Retrieve cookies applicable for a web service request.
93     * Cookie applicability is based on RFC 2965: http://www.ietf.org/rfc/rfc2965.txt
94     *
95     * @param string $request_url The url that the cookies will be for.
96     *
97     * @return array An array containing cookies. E.g. array('name' => 'val');
98     *
99     * @access private
100     */
101    public function getCookies ($request_url)
102    {
103        if (!count($this->_cookies)) {
104            return array();
105        }
106
107        // If our request URL can't be parsed, no cookies apply.
108        $target = parse_url($request_url);
109        if ($target === false) {
110            return array();
111        }
112
113        $this->expireCookies();
114
115        $matching_cookies = array();
116        foreach ($this->_cookies as $key => $cookie) {
117            if ($this->cookieMatchesTarget($cookie, $target)) {
118                $matching_cookies[$cookie['name']] = $cookie['value'];
119            }
120        }
121        return $matching_cookies;
122    }
123
124
125    /**
126     * Parse Cookies without PECL
127     * From the comments in http://php.net/manual/en/function.http-parse-cookie.php
128     *
129     * @param array  $header        array of header lines.
130     * @param string $defaultDomain The domain to use if none is specified in
131     * the cookie.
132     *
133     * @return array of cookies
134     */
135    protected function parseCookieHeaders( $header, $defaultDomain )
136    {
137        phpCAS::traceBegin();
138        $cookies = array();
139        foreach ( $header as $line ) {
140            if ( preg_match('/^Set-Cookie2?: /i', $line)) {
141                $cookies[] = $this->parseCookieHeader($line, $defaultDomain);
142            }
143        }
144
145        phpCAS::traceEnd($cookies);
146        return $cookies;
147    }
148
149    /**
150     * Parse a single cookie header line.
151     *
152     * Based on RFC2965 http://www.ietf.org/rfc/rfc2965.txt
153     *
154     * @param string $line          The header line.
155     * @param string $defaultDomain The domain to use if none is specified in
156     * the cookie.
157     *
158     * @return array
159     */
160    protected function parseCookieHeader ($line, $defaultDomain)
161    {
162        if (!$defaultDomain) {
163            throw new CAS_InvalidArgumentException(
164                '$defaultDomain was not provided.'
165            );
166        }
167
168        // Set our default values
169        $cookie = array(
170            'domain' => $defaultDomain,
171            'path' => '/',
172            'secure' => false,
173        );
174
175        $line = preg_replace('/^Set-Cookie2?: /i', '', trim($line));
176
177        // trim any trailing semicolons.
178        $line = trim($line, ';');
179
180        phpCAS::trace("Cookie Line: $line");
181
182        // This implementation makes the assumption that semicolons will not
183        // be present in quoted attribute values. While attribute values that
184        // contain semicolons are allowed by RFC2965, they are hopefully rare
185        // enough to ignore for our purposes. Most browsers make the same
186        // assumption.
187        $attributeStrings = explode(';', $line);
188
189        foreach ( $attributeStrings as $attributeString ) {
190            // split on the first equals sign and use the rest as value
191            $attributeParts = explode('=', $attributeString, 2);
192
193            $attributeName = trim($attributeParts[0]);
194            $attributeNameLC = strtolower($attributeName);
195
196            if (isset($attributeParts[1])) {
197                $attributeValue = trim($attributeParts[1]);
198                // Values may be quoted strings.
199                if (strpos($attributeValue, '"') === 0) {
200                    $attributeValue = trim($attributeValue, '"');
201                    // unescape any escaped quotes:
202                    $attributeValue = str_replace('\"', '"', $attributeValue);
203                }
204            } else {
205                $attributeValue = null;
206            }
207
208            switch ($attributeNameLC) {
209            case 'expires':
210                $cookie['expires'] = strtotime($attributeValue);
211                break;
212            case 'max-age':
213                $cookie['max-age'] = (int)$attributeValue;
214                // Set an expiry time based on the max-age
215                if ($cookie['max-age']) {
216                    $cookie['expires'] = time() + $cookie['max-age'];
217                } else {
218                    // If max-age is zero, then the cookie should be removed
219                    // imediately so set an expiry before now.
220                    $cookie['expires'] = time() - 1;
221                }
222                break;
223            case 'secure':
224                $cookie['secure'] = true;
225                break;
226            case 'domain':
227            case 'path':
228            case 'port':
229            case 'version':
230            case 'comment':
231            case 'commenturl':
232            case 'discard':
233            case 'httponly':
234            case 'samesite':
235                $cookie[$attributeNameLC] = $attributeValue;
236                break;
237            default:
238                $cookie['name'] = $attributeName;
239                $cookie['value'] = $attributeValue;
240            }
241        }
242
243        return $cookie;
244    }
245
246    /**
247     * Add, update, or remove a cookie.
248     *
249     * @param array $cookie A cookie array as created by parseCookieHeaders()
250     *
251     * @return void
252     *
253     * @access protected
254     */
255    protected function storeCookie ($cookie)
256    {
257        // Discard any old versions of this cookie.
258        $this->discardCookie($cookie);
259        $this->_cookies[] = $cookie;
260
261    }
262
263    /**
264     * Discard an existing cookie
265     *
266     * @param array $cookie An cookie
267     *
268     * @return void
269     *
270     * @access protected
271     */
272    protected function discardCookie ($cookie)
273    {
274        if (!isset($cookie['domain'])
275            || !isset($cookie['path'])
276            || !isset($cookie['path'])
277        ) {
278            throw new CAS_InvalidArgumentException('Invalid Cookie array passed.');
279        }
280
281        foreach ($this->_cookies as $key => $old_cookie) {
282            if ( $cookie['domain'] == $old_cookie['domain']
283                && $cookie['path'] == $old_cookie['path']
284                && $cookie['name'] == $old_cookie['name']
285            ) {
286                unset($this->_cookies[$key]);
287            }
288        }
289    }
290
291    /**
292     * Go through our stored cookies and remove any that are expired.
293     *
294     * @return void
295     *
296     * @access protected
297     */
298    protected function expireCookies ()
299    {
300        foreach ($this->_cookies as $key => $cookie) {
301            if (isset($cookie['expires']) && $cookie['expires'] < time()) {
302                unset($this->_cookies[$key]);
303            }
304        }
305    }
306
307    /**
308     * Answer true if cookie is applicable to a target.
309     *
310     * @param array $cookie An array of cookie attributes.
311     * @param array|false $target An array of URL attributes as generated by parse_url().
312     *
313     * @return bool
314     *
315     * @access private
316     */
317    protected function cookieMatchesTarget ($cookie, $target)
318    {
319        if (!is_array($target)) {
320            throw new CAS_InvalidArgumentException(
321                '$target must be an array of URL attributes as generated by parse_url().'
322            );
323        }
324        if (!isset($target['host'])) {
325            throw new CAS_InvalidArgumentException(
326                '$target must be an array of URL attributes as generated by parse_url().'
327            );
328        }
329
330        // Verify that the scheme matches
331        if ($cookie['secure'] && $target['scheme'] != 'https') {
332            return false;
333        }
334
335        // Verify that the host matches
336        // Match domain and mulit-host cookies
337        if (strpos($cookie['domain'], '.') === 0) {
338            // .host.domain.edu cookies are valid for host.domain.edu
339            if (substr($cookie['domain'], 1) == $target['host']) {
340                // continue with other checks
341            } else {
342                // non-exact host-name matches.
343                // check that the target host a.b.c.edu is within .b.c.edu
344                $pos = strripos($target['host'], $cookie['domain']);
345                if (!$pos) {
346                    return false;
347                }
348                // verify that the cookie domain is the last part of the host.
349                if ($pos + strlen($cookie['domain']) != strlen($target['host'])) {
350                    return false;
351                }
352                // verify that the host name does not contain interior dots as per
353                // RFC 2965 section 3.3.2  Rejecting Cookies
354                // http://www.ietf.org/rfc/rfc2965.txt
355                $hostname = substr($target['host'], 0, $pos);
356                if (strpos($hostname, '.') !== false) {
357                    return false;
358                }
359            }
360        } else {
361            // If the cookie host doesn't begin with '.',
362            // the host must case-insensitive match exactly
363            if (strcasecmp($target['host'], $cookie['domain']) !== 0) {
364                return false;
365            }
366        }
367
368        // Verify that the port matches
369        if (isset($cookie['ports'])
370            && !in_array($target['port'], $cookie['ports'])
371        ) {
372            return false;
373        }
374
375        // Verify that the path matches
376        if (strpos($target['path'], $cookie['path']) !== 0) {
377            return false;
378        }
379
380        return true;
381    }
382
383}
384
385?>
386