Browse Source

Merge pull request #10446 from cakephp/3next-client-cookie-collection

3.next - Add Client specific methods to CookieCollection
Mark Story 9 years ago
parent
commit
66ea5fcc8f

+ 30 - 0
src/Http/Cookie/Cookie.php

@@ -293,6 +293,16 @@ class Cookie implements CookieInterface
     }
 
     /**
+     * Get the path attribute.
+     *
+     * @return string
+     */
+    public function getPath()
+    {
+        return $this->path;
+    }
+
+    /**
      * Create a cookie with an updated domain
      *
      * @param string $domain Domain to set
@@ -308,6 +318,16 @@ class Cookie implements CookieInterface
     }
 
     /**
+     * Get the domain attribute.
+     *
+     * @return string
+     */
+    public function getDomain()
+    {
+        return $this->domain;
+    }
+
+    /**
      * Validate that an argument is a string
      *
      * @param string $value The value to validate.
@@ -406,6 +426,16 @@ class Cookie implements CookieInterface
     }
 
     /**
+     * Get the current expiry time
+     *
+     * @return int|null Timestamp of expiry or null
+     */
+    public function getExpiry()
+    {
+        return $this->expiresAt;
+    }
+
+    /**
      * Create a new cookie that will virtually never expire.
      *
      * @return static

+ 169 - 8
src/Http/Cookie/CookieCollection.php

@@ -15,8 +15,11 @@ namespace Cake\Http\Cookie;
 
 use ArrayIterator;
 use Countable;
+use DateTime;
 use InvalidArgumentException;
 use IteratorAggregate;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
 
 /**
  * Cookie Collection
@@ -62,20 +65,22 @@ class CookieCollection implements IteratorAggregate, Countable
     /**
      * Add a cookie and get an updated collection.
      *
+     * Cookie names do not have to be unique in a collection, but
+     * having duplicate cookie names will change how get() behaves.
+     *
      * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie instance to add.
      * @return static
      */
     public function add(CookieInterface $cookie)
     {
-        $key = mb_strtolower($cookie->getName());
         $new = clone $this;
-        $new->cookies[$key] = $cookie;
+        $new->cookies[] = $cookie;
 
         return $new;
     }
 
     /**
-     * Get a cookie by name
+     * Get the first cookie by name.
      *
      * If the provided name matches a URL (matches `#^https?://#`) this method
      * will assume you want a list of cookies that match that URL. This is
@@ -88,8 +93,10 @@ class CookieCollection implements IteratorAggregate, Countable
     public function get($name)
     {
         $key = mb_strtolower($name);
-        if (isset($this->cookies[$key])) {
-            return $this->cookies[$key];
+        foreach ($this->cookies as $cookie) {
+            if (mb_strtolower($cookie->getName()) === $key) {
+                return $cookie;
+            }
         }
 
         return null;
@@ -103,11 +110,18 @@ class CookieCollection implements IteratorAggregate, Countable
      */
     public function has($name)
     {
-        return isset($this->cookies[mb_strtolower($name)]);
+        $key = mb_strtolower($name);
+        foreach ($this->cookies as $cookie) {
+            if (mb_strtolower($cookie->getName()) === $key) {
+                return true;
+            }
+        }
+
+        return false;
     }
 
     /**
-     * Remove a cookie from the collection and get a new collection
+     * Create a new collection with all cookies matching $name removed.
      *
      * If the cookie is not in the collection, this method will do nothing.
      *
@@ -117,7 +131,12 @@ class CookieCollection implements IteratorAggregate, Countable
     public function remove($name)
     {
         $new = clone $this;
-        unset($new->cookies[mb_strtolower($name)]);
+        $key = mb_strtolower($name);
+        foreach ($new->cookies as $i => $cookie) {
+            if (mb_strtolower($cookie->getName()) === $key) {
+                unset($new->cookies[$i]);
+            }
+        }
 
         return $new;
     }
@@ -154,4 +173,146 @@ class CookieCollection implements IteratorAggregate, Countable
     {
         return new ArrayIterator($this->cookies);
     }
+
+    /**
+     * Add cookies that match the path/domain/expiration to the request.
+     *
+     * This allows CookieCollections to be used as a 'cookie jar' in an HTTP client
+     * situation. Cookies that match the request's domain + path that are not expired
+     * when this method is called will be applied to the request.
+     *
+     * @param \Psr\Http\Message\RequestInterface $request The request to update.
+     * @return \Psr\Http\Message\RequestInterface An updated request.
+     */
+    public function addToRequest(RequestInterface $request)
+    {
+        $uri = $request->getUri();
+        $path = $uri->getPath();
+        $host = $uri->getHost();
+        $scheme = $uri->getScheme();
+
+        $out = [];
+        foreach ($this->cookies as $cookie) {
+            if ($scheme === 'http' && $cookie->isSecure()) {
+                continue;
+            }
+            if (strpos($path, $cookie->getPath()) !== 0) {
+                continue;
+            }
+            $domain = $cookie->getDomain();
+            $leadingDot = substr($domain, 0, 1) === '.';
+            if ($leadingDot) {
+                $domain = ltrim($domain, '.');
+            }
+
+            if ($cookie->getExpiry() && time() > $cookie->getExpiry()) {
+                continue;
+            }
+
+            $pattern = '/' . preg_quote($domain, '/') . '$/';
+            if (!preg_match($pattern, $host)) {
+                continue;
+            }
+
+            $out[$cookie->getName()] = $cookie->getValue();
+        }
+        $cookies = array_merge($request->getCookieParams(), $out);
+
+        return $request->withCookieParams($cookies);
+    }
+
+    /**
+     * Create a new collection that includes cookies from the response.
+     *
+     * @param \Psr\Http\Message\ResponseInterface $response Response to extract cookies from.
+     * @param \Psr\Http\Message\RequestInterface $request Request to get cookie context from.
+     * @return static
+     */
+    public function addFromResponse(ResponseInterface $response, RequestInterface $request)
+    {
+        $uri = $request->getUri();
+        $host = $uri->getHost();
+        $path = $uri->getPath() ?: '/';
+
+        $header = $response->getHeader('Set-Cookie');
+        $cookies = $this->parseSetCookieHeader($header);
+        $new = clone $this;
+        foreach ($cookies as $name => $cookie) {
+            // Apply path/domain from request if the cookie
+            // didn't have one.
+            if (!$cookie->getDomain()) {
+                $cookie = $cookie->withDomain($host);
+            }
+            if (!$cookie->getPath()) {
+                $cookie = $cookie->withPath($path);
+            }
+
+            $expires = $cookie->getExpiry();
+            // Don't store expired cookies
+            if ($expires && $expires <= time()) {
+                continue;
+            }
+            $new->cookies[] = $cookie;
+        }
+
+        return $new;
+    }
+
+    /**
+     * Parse Set-Cookie headers into array
+     *
+     * @param array $values List of Set-Cookie Header values.
+     * @return \Cake\Http\Cookie\Cookie[] An array of cookie objects
+     */
+    protected function parseSetCookieHeader($values)
+    {
+        $cookies = [];
+        foreach ($values as $value) {
+            $value = rtrim($value, ';');
+            $parts = preg_split('/\;[ \t]*/', $value);
+
+            $name = false;
+            $cookie = [
+                'value' => '',
+                'path' => '',
+                'domain' => '',
+                'secure' => false,
+                'httponly' => false,
+                'expires' => null
+            ];
+            foreach ($parts as $i => $part) {
+                if (strpos($part, '=') !== false) {
+                    list($key, $value) = explode('=', $part, 2);
+                } else {
+                    $key = $part;
+                    $value = true;
+                }
+                if ($i === 0) {
+                    $name = $key;
+                    $cookie['value'] = urldecode($value);
+                    continue;
+                }
+                $key = strtolower($key);
+                if (!strlen($cookie[$key])) {
+                    $cookie[$key] = $value;
+                }
+            }
+            $expires = null;
+            if ($cookie['expires']) {
+                $expires = new DateTime($cookie['expires']);
+            }
+
+            $cookies[] = new Cookie(
+                $name,
+                $cookie['value'],
+                $expires,
+                $cookie['path'],
+                $cookie['domain'],
+                $cookie['secure'],
+                $cookie['httponly']
+            );
+        }
+
+        return $cookies;
+    }
 }

+ 200 - 4
tests/TestCase/Http/Cookie/CookieCollectionTest.php

@@ -14,7 +14,10 @@ namespace Cake\Test\TestCase\Http\Cookie;
 
 use Cake\Http\Cookie\Cookie;
 use Cake\Http\Cookie\CookieCollection;
+use Cake\Http\Response;
+use Cake\Http\ServerRequest;
 use Cake\TestSuite\TestCase;
+use DateTime;
 
 /**
  * Cookie collection test.
@@ -90,12 +93,24 @@ class CookieCollectionTest extends TestCase
         $this->assertFalse($collection->has('remember_me'), 'Original instance not modified');
         $this->assertTrue($new->has('remember_me'));
         $this->assertSame($remember, $new->get('remember_me'));
+    }
 
+    /**
+     * Cookie collections need to support duplicate cookie names because
+     * of use cases in Http\Client
+     *
+     * @return void
+     */
+    public function testAddDuplicates()
+    {
+        $remember = new Cookie('remember_me', 'yes');
         $rememberNo = new Cookie('remember_me', 'no');
-        $second = $new->add($remember)->add($rememberNo);
-        $this->assertCount(1, $second);
-        $this->assertNotSame($second, $new);
-        $this->assertSame($rememberNo, $second->get('remember_me'));
+        $collection = new CookieCollection([]);
+        $new = $collection->add($remember)->add($rememberNo);
+
+        $this->assertCount(2, $new);
+        $this->assertNotSame($new, $collection);
+        $this->assertSame($remember, $new->get('remember_me'), 'get() fetches first cookie');
     }
 
     /**
@@ -174,4 +189,185 @@ class CookieCollectionTest extends TestCase
 
         new CookieCollection($array);
     }
+
+    /**
+     * Test adding cookies from a response.
+     *
+     * @return void
+     */
+    public function testAddFromResponse()
+    {
+        $collection = new CookieCollection();
+        $request = new ServerRequest([
+            'url' => '/app'
+        ]);
+        $response = (new Response())
+            ->withAddedHeader('Set-Cookie', 'test=value')
+            ->withAddedHeader('Set-Cookie', 'expiring=soon; Expires=Wed, 09-Jun-2021 10:18:14 GMT; Path=/; HttpOnly; Secure;')
+            ->withAddedHeader('Set-Cookie', 'session=123abc; Domain=www.example.com');
+        $new = $collection->addFromResponse($response, $request);
+        $this->assertNotSame($new, $collection, 'Should clone collection');
+
+        $this->assertTrue($new->has('test'));
+        $this->assertTrue($new->has('session'));
+        $this->assertTrue($new->has('expiring'));
+        $this->assertSame('value', $new->get('test')->getValue());
+        $this->assertSame('123abc', $new->get('session')->getValue());
+        $this->assertSame('soon', $new->get('expiring')->getValue());
+
+        $this->assertSame('/app', $new->get('test')->getPath(), 'cookies should inherit request path');
+        $this->assertSame('/', $new->get('expiring')->getPath(), 'path attribute should be used.');
+
+        $this->assertSame(0, $new->get('test')->getExpiry(), 'No expiry');
+        $this->assertSame(
+            '2021-06-09 10:18:14',
+            date('Y-m-d H:i:s', $new->get('expiring')->getExpiry()),
+            'Has expiry'
+        );
+        $session = $new->get('session');
+        $this->assertSame(0, $session->getExpiry(), 'No expiry');
+        $this->assertSame('www.example.com', $session->getDomain(), 'Has domain');
+    }
+
+    /**
+     * Test adding cookies that contain URL encoded data
+     *
+     * @return void
+     */
+    public function testAddFromResponseValueUrldecodeData()
+    {
+        $collection = new CookieCollection();
+        $request = new ServerRequest([
+            'url' => '/app'
+        ]);
+        $response = (new Response())
+            ->withAddedHeader('Set-Cookie', 'test=val%3Bue; Path=/example; Secure;');
+        $new = $collection->addFromResponse($response, $request);
+        $this->assertTrue($new->has('test'));
+
+        $test = $new->get('test');
+        $this->assertSame('val;ue', $test->getValue());
+        $this->assertSame('/example', $test->getPath());
+    }
+
+    /**
+     * Test adding cookies from a response ignores expired cookies
+     *
+     * @return void
+     */
+    public function testAddFromResponseIgnoreExpired()
+    {
+        $collection = new CookieCollection();
+        $request = new ServerRequest([
+            'url' => '/app'
+        ]);
+        $response = (new Response())
+            ->withAddedHeader('Set-Cookie', 'test=value')
+            ->withAddedHeader('Set-Cookie', 'expired=soon; Expires=Wed, 09-Jun-2012 10:18:14 GMT; Path=/;');
+        $new = $collection->addFromResponse($response, $request);
+        $this->assertFalse($new->has('expired'), 'Should drop expired cookies');
+    }
+
+    /**
+     * Test adding cookies from the collection to request.
+     *
+     * @return void
+     */
+    public function testAddToRequest()
+    {
+        $collection = new CookieCollection();
+        $collection = $collection
+            ->add(new Cookie('api', 'A', null, '/api', 'example.com'))
+            ->add(new Cookie('blog', 'b', null, '/blog', 'blog.example.com'))
+            ->add(new Cookie('expired', 'ex', new DateTime('-2 seconds'), '/', 'example.com'));
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'example.com',
+                'REQUEST_URI' => '/api'
+            ]
+        ]);
+        $request = $collection->addToRequest($request);
+        $this->assertCount(1, $request->getCookieParams());
+        $this->assertSame(['api' => 'A'], $request->getCookieParams());
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'example.com',
+                'REQUEST_URI' => '/'
+            ]
+        ]);
+        $request = $collection->addToRequest($request);
+        $this->assertCount(0, $request->getCookieParams());
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'example.com',
+                'REQUEST_URI' => '/blog'
+            ]
+        ]);
+        $request = $collection->addToRequest($request);
+        $this->assertCount(0, $request->getCookieParams(), 'domain matching should apply');
+
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'foo.blog.example.com',
+                'REQUEST_URI' => '/blog'
+            ]
+        ]);
+        $request = $collection->addToRequest($request);
+        $this->assertCount(1, $request->getCookieParams(), 'domain matching should apply');
+        $this->assertSame(['blog' => 'b'], $request->getCookieParams());
+    }
+
+    /**
+     * Test adding cookies ignores leading dot
+     *
+     * @return void
+     */
+    public function testAddToRequestLeadingDot()
+    {
+        $collection = new CookieCollection();
+        $collection = $collection
+            ->add(new Cookie('public', 'b', null, '/', '.example.com'));
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'example.com',
+                'REQUEST_URI' => '/blog'
+            ]
+        ]);
+        $request = $collection->addToRequest($request);
+        $this->assertSame(['public' => 'b'], $request->getCookieParams());
+    }
+
+    /**
+     * Test adding cookies checks the secure crumb
+     *
+     * @return void
+     */
+    public function testAddToRequestSecureCrumb()
+    {
+        $collection = new CookieCollection();
+        $collection = $collection
+            ->add(new Cookie('secret', 'A', null, '/', 'example.com', true))
+            ->add(new Cookie('public', 'b', null, '/', '.example.com', false));
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTPS' => 'on',
+                'HTTP_HOST' => 'example.com',
+                'REQUEST_URI' => '/api'
+            ]
+        ]);
+        $request = $collection->addToRequest($request);
+        $this->assertSame(['secret' => 'A', 'public' => 'b'], $request->getCookieParams());
+
+        // no HTTPS set.
+        $request = new ServerRequest([
+            'environment' => [
+                'HTTP_HOST' => 'example.com',
+                'REQUEST_URI' => '/api'
+            ]
+        ]);
+        $request = $collection->addToRequest($request);
+        $this->assertSame(['public' => 'b'], $request->getCookieParams());
+    }
 }