Browse Source

Merge pull request #10513 from cakephp/3next-cookie-middleware

3.next - Add Cookie encryption middleware
Mark Story 9 years ago
parent
commit
917b030ae0

+ 1 - 37
src/Http/Cookie/Cookie.php

@@ -48,8 +48,6 @@ use RuntimeException;
 class Cookie implements CookieInterface
 {
 
-    use CookieCryptTrait;
-
     /**
      * Expires attribute format.
      *
@@ -185,7 +183,7 @@ class Cookie implements CookieInterface
         if ($this->isExpanded) {
             $value = $this->_flatten($this->value);
         }
-        $headerValue[] = sprintf('%s=%s', $this->name, urlencode($value));
+        $headerValue[] = sprintf('%s=%s', $this->name, rawurlencode($value));
 
         if ($this->expiresAt) {
             $headerValue[] = sprintf('expires=%s', $this->getFormattedExpires());
@@ -599,40 +597,6 @@ class Cookie implements CookieInterface
     }
 
     /**
-     * Encrypts the cookie value
-     *
-     * @param string|null $key Encryption key
-     * @return $this
-     */
-    public function encrypt($key = null)
-    {
-        if ($key !== null) {
-            $this->setEncryptionKey($key);
-        }
-
-        $this->value = $this->_encrypt($this->value);
-
-        return $this;
-    }
-
-    /**
-     * Decrypts the cookie value
-     *
-     * @param string|null $key Encryption key
-     * @return $this
-     */
-    public function decrypt($key = null)
-    {
-        if ($key !== null) {
-            $this->setEncryptionKey($key);
-        }
-
-        $this->value = $this->_decrypt($this->value);
-
-        return $this;
-    }
-
-    /**
      * Checks if the cookie value was expanded
      *
      * @return bool

+ 0 - 209
src/Http/Cookie/CookieCryptTrait.php

@@ -1,209 +0,0 @@
-<?php
-/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- *
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://cakephp.org CakePHP(tm) Project
- * @since         3.5.0
- * @license       http://www.opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Http\Cookie;
-
-use Cake\Utility\Security;
-use RuntimeException;
-
-/**
- * Cookie Crypt Trait.
- *
- * Provides the encrypt/decrypt logic.
- */
-trait CookieCryptTrait
-{
-
-    /**
-     * Valid cipher names for encrypted cookies.
-     *
-     * @var array
-     */
-    protected $_validCiphers = [
-        'aes'
-    ];
-
-    /**
-     * Encryption cipher
-     *
-     * @param string|bool
-     */
-    protected $encryptionCipher = 'aes';
-
-    /**
-     * The key for encrypting and decrypting the cookie
-     *
-     * @var string
-     */
-    protected $encryptionKey = '';
-
-    /**
-     * Prefix of the encrypted string
-     *
-     * @var string
-     */
-    protected $encryptedStringPrefix = 'Q2FrZQ==.';
-
-    /**
-     * Sets the encryption cipher
-     *
-     * @param string $cipher Cipher
-     * @return $this
-     */
-    public function setEncryptionCipher($cipher)
-    {
-        $this->checkCipher($cipher);
-        $this->encryptionCipher = $cipher;
-
-        return $this;
-    }
-
-    /**
-     * Check if encryption is enabled
-     *
-     * @return bool
-     */
-    public function isEncryptionEnabled()
-    {
-        return is_string($this->encryptionCipher);
-    }
-
-    /**
-     * Disables the encryption
-     *
-     * @return $this
-     */
-    public function disableEncryption()
-    {
-        $this->encryptionCipher = false;
-
-        return $this;
-    }
-
-    /**
-     * Sets the encryption key
-     *
-     * @param string $key Encryption key
-     * @return $this
-     */
-    public function setEncryptionKey($key)
-    {
-        $this->encryptionKey = $key;
-
-        return $this;
-    }
-
-    /**
-     * Returns the encryption key to be used.
-     *
-     * @return string
-     */
-    public function getEncryptionKey()
-    {
-        if ($this->encryptionKey === null) {
-            return Security::getSalt();
-        }
-
-        return $this->encryptionKey;
-    }
-
-    /**
-     * Encrypts $value using public $type method in Security class
-     *
-     * @param string|array $value Value to encrypt
-     * @return string Encoded values
-     */
-    protected function _encrypt($value)
-    {
-        if (is_array($value)) {
-            $value = $this->_flatten($value);
-        }
-
-        $encrypt = $this->encryptionCipher;
-        if ($encrypt === false) {
-            throw new RuntimeException('Encryption is disabled, no cipher provided.');
-        }
-
-        $cipher = null;
-        $key = $this->getEncryptionKey();
-
-        if ($encrypt === 'aes') {
-            $cipher = Security::encrypt($value, $key);
-        }
-
-        return $this->encryptedStringPrefix . base64_encode($cipher);
-    }
-
-    /**
-     * Helper method for validating encryption cipher names.
-     *
-     * @param string $encrypt The cipher name.
-     * @return void
-     * @throws \RuntimeException When an invalid cipher is provided.
-     */
-    protected function checkCipher($encrypt)
-    {
-        if (!in_array($encrypt, $this->_validCiphers)) {
-            $msg = sprintf(
-                'Invalid encryption cipher. Must be one of %s.',
-                implode(', ', $this->_validCiphers)
-            );
-            throw new RuntimeException($msg);
-        }
-    }
-
-    /**
-     * Decrypts $value using public $type method in Security class
-     *
-     * @param string|array $values Values to decrypt
-     * @return string|array Decrypted values
-     */
-    protected function _decrypt($values)
-    {
-        if (is_string($values)) {
-            return $this->_decode($values);
-        }
-
-        $decrypted = [];
-        foreach ($values as $name => $value) {
-            $decrypted[$name] = $this->_decode($value);
-        }
-
-        return $decrypted;
-    }
-
-    /**
-     * Decodes and decrypts a single value.
-     *
-     * @param string $value The value to decode & decrypt.
-     * @return string|array Decoded values.
-     */
-    protected function _decode($value)
-    {
-        if (!$this->isEncryptionEnabled()) {
-            return $this->_expand($value);
-        }
-
-        $key = $this->getEncryptionKey();
-        $encrypt = $this->encryptionCipher;
-
-        $value = base64_decode(substr($value, strlen($this->encryptedStringPrefix)));
-
-        if ($encrypt === 'aes') {
-            $value = Security::decrypt($value, $key);
-        }
-
-        return $this->_expand($value);
-    }
-}

+ 0 - 40
src/Http/Cookie/RequestCookies.php

@@ -1,40 +0,0 @@
-<?php
-/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- *
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://cakephp.org CakePHP(tm) Project
- * @since         3.5.0
- * @license       http://www.opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Http\Cookie;
-
-use InvalidArgumentException;
-use Psr\Http\Message\ServerRequestInterface;
-
-class RequestCookies extends CookieCollection
-{
-
-    /**
-     * Create instance from a server request.
-     *
-     * @param \Psr\Http\Message\ServerRequestInterface $request Request object
-     * @param string $cookieClass Cookie class to use for the cookies
-     * @return \Cake\Http\Cookie\RequestCookies
-     */
-    public static function createFromRequest(ServerRequestInterface $request, $cookieClass = Cookie::class)
-    {
-        $cookies = [];
-        $cookieParams = $request->getCookieParams();
-
-        foreach ($cookieParams as $name => $value) {
-            $cookies[] = new $cookieClass($name, $value);
-        }
-
-        return new static($cookies);
-    }
-}

+ 0 - 36
src/Http/Cookie/ResponseCookies.php

@@ -1,36 +0,0 @@
-<?php
-/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- *
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://cakephp.org CakePHP(tm) Project
- * @since         3.5.0
- * @license       http://www.opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Http\Cookie;
-
-use Psr\Http\Message\ResponseInterface;
-
-class ResponseCookies extends CookieCollection
-{
-
-    /**
-     * Adds the cookies to the response
-     *
-     * @param \Psr\Http\Message\ResponseInterface $response Response object.
-     * @return \Psr\Http\Message\ResponseInterface
-     */
-    public function addToResponse(ResponseInterface $response)
-    {
-        $header = [];
-        foreach ($this->cookies as $setCookie) {
-            $header[] = $setCookie->toHeaderValue();
-        }
-
-        return $response->withAddedHeader('Set-Cookie', $header);
-    }
-}

+ 169 - 0
src/Http/Middleware/EncryptedCookieMiddleware.php

@@ -0,0 +1,169 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.5.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http\Middleware;
+
+use Cake\Http\Cookie\CookieCollection;
+use Cake\Http\Response;
+use Cake\Utility\CookieCryptTrait;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Middlware for encrypting & decrypting cookies.
+ *
+ * This middleware layer will encrypt/decrypt the named cookies with the given key
+ * and cipher type. To support multiple keys/cipher types use this middleware multiple
+ * times.
+ *
+ * Cookies in request data will be decrypted, while cookies in response headers will
+ * be encrypted automatically. If the response is a Cake\Http\Response, the cookie
+ * data set with `withCookie()` and `cookie()`` will also be encrypted.
+ *
+ * The encryption types and padding are compatible with those used by CookieComponent
+ * for backwards compatibility.
+ */
+class EncryptedCookieMiddleware
+{
+    use CookieCryptTrait;
+
+    /**
+     * The list of cookies to encrypt/decrypt
+     * @var array
+     */
+    protected $cookieNames;
+
+    /**
+     * Encrpytion key to use.
+     *
+     * @var string
+     */
+    protected $key;
+
+    /**
+     * Encryption type.
+     *
+     * @var string
+     */
+    protected $cipherType;
+
+    /**
+     * Constructor
+     *
+     * @param array $cookieNames The list of cookie names that should have their values encrypted.
+     * @param string $key The encryption key to use.
+     * @param string $cipherType The cipher type to use. Defaults to 'aes', but can also be 'rijndael' for
+     *   backwards compatibility.
+     */
+    public function __construct(array $cookieNames, $key, $cipherType = 'aes')
+    {
+        $this->cookieNames = $cookieNames;
+        $this->key = $key;
+        $this->cipherType = $cipherType;
+    }
+
+    /**
+     * Apply cookie encryption/decryption.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
+     * @param \Psr\Http\Message\ResponseInterface $response The response.
+     * @param callable $next The next middleware to call.
+     * @return \Psr\Http\Message\ResponseInterface A response.
+     */
+    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next)
+    {
+        if ($request->getCookieParams()) {
+            $request = $this->decodeCookies($request);
+        }
+        $response = $next($request, $response);
+        if ($response->hasHeader('Set-Cookie')) {
+            $response = $this->encodeSetCookieHeader($response);
+        }
+        if ($response instanceof Response) {
+            $response = $this->encodeCookies($response);
+        }
+
+        return $response;
+    }
+
+    /**
+     * Fetch the cookie encryption key.
+     *
+     * Part of the CookieCryptTrait implementation.
+     *
+     * @return string
+     */
+    protected function _getCookieEncryptionKey()
+    {
+        return $this->key;
+    }
+
+    /**
+     * Decode cookies from the request.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to decode cookies from.
+     * @return \Psr\Http\Message\ServerRequestInterface Updated request with decoded cookies.
+     */
+    protected function decodeCookies(ServerRequestInterface $request)
+    {
+        $cookies = $request->getCookieParams();
+        foreach ($this->cookieNames as $name) {
+            if (isset($cookies[$name])) {
+                $cookies[$name] = $this->_decrypt($cookies[$name], $this->cipherType, $this->key);
+            }
+        }
+
+        return $request->withCookieParams($cookies);
+    }
+
+    /**
+     * Encode cookies from a response's CookieCollection.
+     *
+     * @param \Cake\Http\Response $response The response to encode cookies in.
+     * @return \Cake\Http\Response Updated response with encoded cookies.
+     */
+    protected function encodeCookies(Response $response)
+    {
+        $cookies = $response->getCookieCollection();
+        foreach ($cookies as $cookie) {
+            if (in_array($cookie->getName(), $this->cookieNames, true)) {
+                $value = $this->_encrypt($cookie->getValue(), $this->cipherType);
+                $response = $response->withCookie($cookie->withValue($value));
+            }
+        }
+
+        return $response;
+    }
+
+    /**
+     * Encode cookies from a response's Set-Cookie header
+     *
+     * @param \Psr\Http\Message\ResponseInterface $response The response to encode cookies in.
+     * @return \Psr\Http\Message\ResponseInterface Updated response with encoded cookies.
+     */
+    protected function encodeSetCookieHeader(ResponseInterface $response)
+    {
+        $cookies = CookieCollection::createFromHeader($response->getHeader('Set-Cookie'));
+        $header = [];
+        foreach ($cookies as $cookie) {
+            if (in_array($cookie->getName(), $this->cookieNames, true)) {
+                $value = $this->_encrypt($cookie->getValue(), $this->cipherType);
+                $cookie = $cookie->withValue($value);
+            }
+            $header[] = $cookie->toHeaderValue();
+        }
+
+        return $response->withHeader('Set-Cookie', $header);
+    }
+}

+ 0 - 38
tests/TestCase/Http/Cookie/CookieTest.php

@@ -23,13 +23,6 @@ class CookieTest extends TestCase
 {
 
     /**
-     * Encryption key used in the tests
-     *
-     * @var string
-     */
-    protected $encryptionKey = 'someverysecretkeythatisatleast32charslong';
-
-    /**
      * Generate invalid cookie names.
      *
      * @return array
@@ -71,37 +64,6 @@ class CookieTest extends TestCase
     }
 
     /**
-     * Test decrypting the cookie
-     *
-     * @return void
-     */
-    public function testDecrypt()
-    {
-        $value = 'cakephp-rocks-and-is-awesome';
-        $cookie = new Cookie('cakephp', $value);
-        $cookie->encrypt($this->encryptionKey);
-        $this->assertTextStartsWith('Q2FrZQ==.', $cookie->getValue());
-        $cookie->decrypt($this->encryptionKey);
-        $this->assertSame($value, $cookie->getValue());
-    }
-
-    /**
-     * Testing encrypting the cookie
-     *
-     * @return void
-     */
-    public function testEncrypt()
-    {
-        $value = 'cakephp-rocks-and-is-awesome';
-
-        $cookie = new Cookie('cakephp', $value);
-        $cookie->encrypt($this->encryptionKey);
-
-        $this->assertNotEquals($value, $cookie->getValue());
-        $this->assertNotEmpty($cookie->getValue());
-    }
-
-    /**
      * Tests the header value
      *
      * @return void

+ 0 - 62
tests/TestCase/Http/Cookie/RequestCookiesTest.php

@@ -1,62 +0,0 @@
-<?php
-/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://cakephp.org CakePHP(tm) Project
- * @since         3.5.0
- * @license       http://www.opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Test\TestCase\Http\Cookie;
-
-use Cake\Http\Cookie\Cookie;
-use Cake\Http\Cookie\RequestCookies;
-use Cake\Http\ServerRequest;
-use Cake\TestSuite\TestCase;
-
-/**
- * HTTP cookies test.
- */
-class RequestCookiesTest extends TestCase
-{
-
-    /**
-     * Server Request
-     *
-     * @var \Cake\Http\ServerRequest
-     */
-    public $request;
-
-    /**
-     * @inheritDoc
-     */
-    public function setUp()
-    {
-        $this->request = new ServerRequest([
-            'cookies' => [
-                'remember_me' => 'test',
-                'something' => 'test2'
-            ]
-        ]);
-    }
-
-    /**
-     * Test testCreateFromRequest
-     *
-     * @return null
-     */
-    public function testCreateFromRequest()
-    {
-        $result = RequestCookies::createFromRequest($this->request);
-        $this->assertInstanceOf(RequestCookies::class, $result);
-        $this->assertInstanceOf(Cookie::class, $result->get('remember_me'));
-        $this->assertInstanceOf(Cookie::class, $result->get('something'));
-
-        $this->assertTrue($result->has('remember_me'));
-        $this->assertTrue($result->has('something'));
-        $this->assertFalse($result->has('does-not-exist'));
-    }
-}

+ 0 - 51
tests/TestCase/Http/Cookie/ResponseCookiesTest.php

@@ -1,51 +0,0 @@
-<?php
-/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://cakephp.org CakePHP(tm) Project
- * @since         3.5.0
- * @license       http://www.opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Test\TestCase\Http\Cookie;
-
-use Cake\Http\Client\Response;
-use Cake\Http\Cookie\Cookie;
-use Cake\Http\Cookie\ResponseCookies;
-use Cake\TestSuite\TestCase;
-
-/**
- * Response Cookies Test
- */
-class ResponseCookiesTest extends TestCase
-{
-
-    /**
-     * testAddToResponse
-     *
-     * @return void
-     */
-    public function testAddToResponse()
-    {
-        $cookies = [
-            new Cookie('one', 'one'),
-            new Cookie('two', 'two')
-        ];
-
-        $responseCookies = new ResponseCookies($cookies);
-
-        $response = new Response();
-        $response = $responseCookies->addToResponse($response);
-
-        $expected = [
-            'Set-Cookie' => [
-                'one=one',
-                'two=two'
-            ]
-        ];
-        $this->assertEquals($expected, $response->getHeaders());
-    }
-}

+ 125 - 0
tests/TestCase/Http/Middleware/EncryptedCookieMiddlewareTest.php

@@ -0,0 +1,125 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.3.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Http\Middleware;
+
+use Cake\Http\Cookie\CookieCollection;
+use Cake\Http\Middleware\EncryptedCookieMiddleware;
+use Cake\Http\Response;
+use Cake\Http\ServerRequest;
+use Cake\TestSuite\TestCase;
+use Cake\Utility\CookieCryptTrait;
+
+/**
+ * Test for EncryptedCookieMiddleware
+ */
+class EncryptedCookieMiddlewareTest extends TestCase
+{
+    use CookieCryptTrait;
+
+    protected $middleware;
+
+    protected function _getCookieEncryptionKey()
+    {
+        return 'super secret key that no one can guess';
+    }
+
+    /**
+     * Setup
+     */
+    public function setUp()
+    {
+        $this->middleware = new EncryptedCookieMiddleware(
+            ['secret', 'ninja'],
+            $this->_getCookieEncryptionKey(),
+            'aes'
+        );
+    }
+
+    /**
+     * Test decoding request cookies
+     *
+     * @return void
+     */
+    public function testDecodeRequestCookies()
+    {
+        $request = new ServerRequest(['url' => '/cookies/nom']);
+        $request = $request->withCookieParams([
+            'plain' => 'always plain',
+            'secret' => $this->_encrypt('decoded', 'aes')
+        ]);
+        $this->assertNotEquals('decoded', $request->getCookie('decoded'));
+
+        $response = new Response();
+        $next = function ($req, $res) {
+            $this->assertSame('decoded', $req->getCookie('secret'));
+            $this->assertSame('always plain', $req->getCookie('plain'));
+
+            return $res->withHeader('called', 'yes');
+        };
+        $middleware = $this->middleware;
+        $response = $middleware($request, $response, $next);
+        $this->assertSame('yes', $response->getHeaderLine('called'), 'Inner middleware not invoked');
+    }
+
+    /**
+     * Test encoding cookies in the set-cookie header.
+     *
+     * @return void
+     */
+    public function testEncodeResponseSetCookieHeader()
+    {
+        $request = new ServerRequest(['url' => '/cookies/nom']);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res->withAddedHeader('Set-Cookie', 'secret=be%20quiet')
+                ->withAddedHeader('Set-Cookie', 'plain=in%20clear')
+                ->withAddedHeader('Set-Cookie', 'ninja=shuriken');
+        };
+        $middleware = $this->middleware;
+        $response = $middleware($request, $response, $next);
+        $this->assertNotContains('ninja=shuriken', $response->getHeaderLine('Set-Cookie'));
+        $this->assertContains('plain=in%20clear', $response->getHeaderLine('Set-Cookie'));
+
+        $cookies = CookieCollection::createFromHeader($response->getHeader('Set-Cookie'));
+        $this->assertTrue($cookies->has('ninja'));
+        $this->assertEquals(
+            'shuriken',
+            $this->_decrypt($cookies->get('ninja')->getValue(), 'aes')
+        );
+    }
+
+    /**
+     * Test encoding cookies in the cookie collection.
+     *
+     * @return void
+     */
+    public function testEncodeResponseCookieData()
+    {
+        $request = new ServerRequest(['url' => '/cookies/nom']);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res->withCookie('secret', 'be quiet')
+                ->withCookie('plain', 'in clear')
+                ->withCookie('ninja', 'shuriken');
+        };
+        $middleware = $this->middleware;
+        $response = $middleware($request, $response, $next);
+        $this->assertNotSame('shuriken', $response->getCookie('ninja'));
+        $this->assertEquals(
+            'shuriken',
+            $this->_decrypt($response->getCookie('ninja')['value'], 'aes')
+        );
+    }
+}