CookieJar.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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. $cookie[$attributeNameLC] = $attributeValue;
  209. break;
  210. default:
  211. $cookie['name'] = $attributeName;
  212. $cookie['value'] = $attributeValue;
  213. }
  214. }
  215. return $cookie;
  216. }
  217. /**
  218. * Add, update, or remove a cookie.
  219. *
  220. * @param array $cookie A cookie array as created by parseCookieHeaders()
  221. *
  222. * @return void
  223. *
  224. * @access protected
  225. */
  226. protected function storeCookie ($cookie)
  227. {
  228. // Discard any old versions of this cookie.
  229. $this->discardCookie($cookie);
  230. $this->_cookies[] = $cookie;
  231. }
  232. /**
  233. * Discard an existing cookie
  234. *
  235. * @param array $cookie An cookie
  236. *
  237. * @return void
  238. *
  239. * @access protected
  240. */
  241. protected function discardCookie ($cookie)
  242. {
  243. if (!isset($cookie['domain'])
  244. || !isset($cookie['path'])
  245. || !isset($cookie['path'])
  246. ) {
  247. throw new CAS_InvalidArgumentException('Invalid Cookie array passed.');
  248. }
  249. foreach ($this->_cookies as $key => $old_cookie) {
  250. if ( $cookie['domain'] == $old_cookie['domain']
  251. && $cookie['path'] == $old_cookie['path']
  252. && $cookie['name'] == $old_cookie['name']
  253. ) {
  254. unset($this->_cookies[$key]);
  255. }
  256. }
  257. }
  258. /**
  259. * Go through our stored cookies and remove any that are expired.
  260. *
  261. * @return void
  262. *
  263. * @access protected
  264. */
  265. protected function expireCookies ()
  266. {
  267. foreach ($this->_cookies as $key => $cookie) {
  268. if (isset($cookie['expires']) && $cookie['expires'] < time()) {
  269. unset($this->_cookies[$key]);
  270. }
  271. }
  272. }
  273. /**
  274. * Answer true if cookie is applicable to a target.
  275. *
  276. * @param array $cookie An array of cookie attributes.
  277. * @param array|false $target An array of URL attributes as generated by parse_url().
  278. *
  279. * @return bool
  280. *
  281. * @access private
  282. */
  283. protected function cookieMatchesTarget ($cookie, $target)
  284. {
  285. if (!is_array($target)) {
  286. throw new CAS_InvalidArgumentException(
  287. '$target must be an array of URL attributes as generated by parse_url().'
  288. );
  289. }
  290. if (!isset($target['host'])) {
  291. throw new CAS_InvalidArgumentException(
  292. '$target must be an array of URL attributes as generated by parse_url().'
  293. );
  294. }
  295. // Verify that the scheme matches
  296. if ($cookie['secure'] && $target['scheme'] != 'https') {
  297. return false;
  298. }
  299. // Verify that the host matches
  300. // Match domain and mulit-host cookies
  301. if (strpos($cookie['domain'], '.') === 0) {
  302. // .host.domain.edu cookies are valid for host.domain.edu
  303. if (substr($cookie['domain'], 1) == $target['host']) {
  304. // continue with other checks
  305. } else {
  306. // non-exact host-name matches.
  307. // check that the target host a.b.c.edu is within .b.c.edu
  308. $pos = strripos($target['host'], $cookie['domain']);
  309. if (!$pos) {
  310. return false;
  311. }
  312. // verify that the cookie domain is the last part of the host.
  313. if ($pos + strlen($cookie['domain']) != strlen($target['host'])) {
  314. return false;
  315. }
  316. // verify that the host name does not contain interior dots as per
  317. // RFC 2965 section 3.3.2 Rejecting Cookies
  318. // http://www.ietf.org/rfc/rfc2965.txt
  319. $hostname = substr($target['host'], 0, $pos);
  320. if (strpos($hostname, '.') !== false) {
  321. return false;
  322. }
  323. }
  324. } else {
  325. // If the cookie host doesn't begin with '.',
  326. // the host must case-insensitive match exactly
  327. if (strcasecmp($target['host'], $cookie['domain']) !== 0) {
  328. return false;
  329. }
  330. }
  331. // Verify that the port matches
  332. if (isset($cookie['ports'])
  333. && !in_array($target['port'], $cookie['ports'])
  334. ) {
  335. return false;
  336. }
  337. // Verify that the path matches
  338. if (strpos($target['path'], $cookie['path']) !== 0) {
  339. return false;
  340. }
  341. return true;
  342. }
  343. }
  344. ?>