CookieJar.php 12 KB

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