From a59b29d8da11547f433df4f0468ad3662d0b03e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Sun, 12 Feb 2017 22:47:21 +0100 Subject: [PATCH] Initial commit of the new cookie implementation --- src/Http/Client/Cookie/Cookie.php | 387 ++++++++++++++++++ src/Http/Client/Cookie/CookieCollection.php | 59 +++ src/Http/Client/Cookie/CookieCryptTrait.php | 184 +++++++++ src/Http/Client/Cookie/CookieInterface.php | 25 ++ src/Http/Client/Cookie/RequestCookies.php | 125 ++++++ src/Http/Client/Cookie/ResponseCookies.php | 36 ++ .../Http/Client/Cookie/CookieTest.php | 108 +++++ .../Http/Client/Cookie/RequestCookiesTest.php | 63 +++ .../Client/Cookie/ResponseCookiesTest.php | 25 ++ 9 files changed, 1012 insertions(+) create mode 100644 src/Http/Client/Cookie/Cookie.php create mode 100644 src/Http/Client/Cookie/CookieCollection.php create mode 100644 src/Http/Client/Cookie/CookieCryptTrait.php create mode 100644 src/Http/Client/Cookie/CookieInterface.php create mode 100644 src/Http/Client/Cookie/RequestCookies.php create mode 100644 src/Http/Client/Cookie/ResponseCookies.php create mode 100644 tests/TestCase/Http/Client/Cookie/CookieTest.php create mode 100644 tests/TestCase/Http/Client/Cookie/RequestCookiesTest.php create mode 100644 tests/TestCase/Http/Client/Cookie/ResponseCookiesTest.php diff --git a/src/Http/Client/Cookie/Cookie.php b/src/Http/Client/Cookie/Cookie.php new file mode 100644 index 00000000000..e00512a0714 --- /dev/null +++ b/src/Http/Client/Cookie/Cookie.php @@ -0,0 +1,387 @@ +validateName($name); + $this->setName($name); + $this->setValue($value); + } + + /** + * Returns a header value as string + * + * @return string + */ + public function toHeaderValue() + { + $headerValue = sprintf('%s=%s', $this->name, urlencode($this->value)); + if ($this->expiresAt !== 0) { + $headerValue .= sprintf( + '; expires=%s', + gmdate('D, d-M-Y H:i:s T', $this->expiresAt) + ); + } + if (empty($this->path) === false) { + $headerValue .= sprintf('; path=%s', $this->path); + } + if (empty($this->domain) === false) { + $headerValue .= sprintf('; domain=%s', $this->domain); + } + if ($this->secure) { + $headerValue .= '; secure'; + } + if ($this->httpOnly) { + $headerValue .= '; httponly'; + } + + return $headerValue; + } + + /** + * Sets the cookie name + * + * @param string $name Name of the cookie + * @return $this + */ + public function setName($name) + { + $this->validateName($name); + $this->name = $name; + + return $this; + } + + /** + * Gets the cookie name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Validates the cookie name + * + * @throws \InvalidArgumentException + * @param string $name Name of the cookie + * @return void + */ + protected function validateName($name) + { + if (preg_match("/[=,; \t\r\n\013\014]/", $name)) { + throw new InvalidArgumentException( + sprintf('The cookie name `%s` contains invalid characters.', $name) + ); + } + + if (empty($name)) { + throw new InvalidArgumentException('The cookie name cannot be empty.'); + } + } + + /** + * Gets the raw cookie value + * + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * Sets the raw cookie data + * + * @param string $value Value of the cookie to set + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * Sets the path + * + * @param string $path Sets the path + * @return $this + */ + public function setPath($path) + { + $this->path = $path; + + return $this; + } + + /** + * Sets the domain + * + * @param string $domain Domain to set + * @return $this + */ + public function setDomain($domain) + { + $this->domain = $domain; + + return $this; + } + + /** + * Sets the expiration date + * + * @param \DateTimeInterface $dateTime Date time object + * @return $this + */ + public function expiresAt(DateTimeInterface $dateTime) + { + $this->expiresAt = (int)$dateTime->format('U'); + + return $this; + } + + /** + * Checks if a value exists in the cookie data + * + * @param string $path Path to check + * @return bool + */ + public function check($path) + { + return Hash::check($this->value, $path); + } + + /** + * Writes data to the cookie + * + * @param string $path Path to write to + * @param mixer $value Value to write + * @return $this + */ + public function write($path, $value) + { + if (!$this->isExpanded) { + throw new RuntimeException('The Cookie data has not been expanded'); + } + + Hash::insert($this->value, $path, $value); + + return $this; + } + + /** + * Read data from the cookie + * + * @param string $path Path to read the data from + * @return mixed + */ + public function read($path = null) + { + if (!$this->isExpanded) { + throw new \RuntimeException('The Cookie data has not been expanded'); + } + + if ($path === null) { + return $this->data; + } + + return Hash::get($this->data, $path); + } + + /** + * Encrypts the cookie value + * + * @param string $key Encryption key + * @return $this + */ + public function encrypt($key) + { + $this->encryptionKey = $key; + $this->value = $this->_encrypt($this->value, 'aes', $key); + + return $this; + } + + /** + * Decrypts the cookie value + * + * @param string $key Encryption key + * @return $this + */ + public function decrypt($key) + { + $this->encryptionKey = $key; + $this->value = $this->_decrypt($this->value, 'aes', $key); + + return $this; + } + + /** + * Expands a serialized cookie value + * + * @return $this + */ + public function expand() + { + if (!$this->isExpanded) { + $this->data = $this->_explode($this->value); + $this->isExpanded = true; + } + + return $this; + } + + /** + * Serialized the data to a string + * + * @return $this + */ + public function flatten() + { + if ($this->isExpanded) { + $this->value = $this->_implode($this->value); + $this->isExpanded = false; + } + + return $this; + } + + /** + * Checks if the cookie value was expanded + * + * @return bool + */ + public function isExpanded() + { + return $this->isExpanded; + } + + /** + * Sets the encryption key + * + * @param string $key Encryption key + * @return $this + */ + public function setEncryptionKey($key) + { + $this->encryptionKey = $key; + + return $this; + } + + /** + * Gets the cryptographic key + * + * @return string + */ + public function getEncryptionKey() + { + if (empty($this->encryptionKey)) { + return Security::salt(); + } + + return $this->encryptionKey; + } +} diff --git a/src/Http/Client/Cookie/CookieCollection.php b/src/Http/Client/Cookie/CookieCollection.php new file mode 100644 index 00000000000..92dc74f4b59 --- /dev/null +++ b/src/Http/Client/Cookie/CookieCollection.php @@ -0,0 +1,59 @@ +checkCookies($cookies); + foreach ($cookies as $cookie) { + $name = $cookie->getName(); + $key = mb_strtolower($name); + $this->cookies[$key] = $cookie; + } + } + + /** + * Checks if only valid cookie objects are in the array + * + * @param array $cookies Array of cookie objects + * @return void + */ + protected function checkCookies(array $cookies) + { + foreach ($cookies as $index => $cookie) { + if (!$cookie instanceof CookieInterface) { + throw new InvalidArgumentException( + sprintf( + 'Expected %s[] as $cookies but instead got `%s` at index %d', + static::class, + is_object($cookie) ? get_class($cookie) : gettype($cookie), + $index + ) + ); + } + } + } +} diff --git a/src/Http/Client/Cookie/CookieCryptTrait.php b/src/Http/Client/Cookie/CookieCryptTrait.php new file mode 100644 index 00000000000..358a7b64854 --- /dev/null +++ b/src/Http/Client/Cookie/CookieCryptTrait.php @@ -0,0 +1,184 @@ +_implode($value); + } + if ($encrypt === false) { + return $value; + } + $this->checkCipher($encrypt); + $prefix = "Q2FrZQ==."; + $cipher = null; + if ($key === null) { + $key = $this->getCryptoKey(); + } + if ($encrypt === 'rijndael') { + $cipher = Security::rijndael($value, $key, 'encrypt'); + } + if ($encrypt === 'aes') { + $cipher = Security::encrypt($value, $key); + } + + return $prefix . 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 array $values Values to decrypt + * @param string|bool $mode Encryption mode + * @param string|null $key Used as the security salt if specified. + * @return string|array Decrypted values + */ + protected function _decrypt($values, $mode, $key = null) + { + if (is_string($values)) { + return $this->_decode($values, $mode, $key); + } + + $decrypted = []; + foreach ($values as $name => $value) { + $decrypted[$name] = $this->_decode($value, $mode, $key); + } + + return $decrypted; + } + + /** + * Decodes and decrypts a single value. + * + * @param string $value The value to decode & decrypt. + * @param string|false $encrypt The encryption cipher to use. + * @param string|null $key Used as the security salt if specified. + * @return string|array Decoded values. + */ + protected function _decode($value, $encrypt, $key) + { + if (!$encrypt) { + return $this->_explode($value); + } + + $this->checkCipher($encrypt); + $prefix = 'Q2FrZQ==.'; + $value = base64_decode(substr($value, strlen($prefix))); + if ($key === null) { + $key = $this->getEncryptionKey(); + } + if ($encrypt === 'rijndael') { + $value = Security::rijndael($value, $key, 'decrypt'); + } + if ($encrypt === 'aes') { + $value = Security::decrypt($value, $key); + } + + return $this->_explode($value); + } + + /** + * Implode method to keep keys are multidimensional arrays + * + * @param array $array Map of key and values + * @return string A json encoded string. + */ + protected function _implode(array $array) + { + return json_encode($array); + } + + /** + * Explode method to return array from string set in CookieComponent::_implode() + * Maintains reading backwards compatibility with 1.x CookieComponent::_implode(). + * + * @param string $string A string containing JSON encoded data, or a bare string. + * @return string|array Map of key and values + */ + protected function _explode($string) + { + $first = substr($string, 0, 1); + if ($first === '{' || $first === '[') { + $ret = json_decode($string, true); + + return ($ret !== null) ? $ret : $string; + } + $array = []; + foreach (explode(',', $string) as $pair) { + $key = explode('|', $pair); + if (!isset($key[1])) { + return $key[0]; + } + $array[$key[0]] = $key[1]; + } + + return $array; + } +} diff --git a/src/Http/Client/Cookie/CookieInterface.php b/src/Http/Client/Cookie/CookieInterface.php new file mode 100644 index 00000000000..ef34fffb292 --- /dev/null +++ b/src/Http/Client/Cookie/CookieInterface.php @@ -0,0 +1,25 @@ +getCookieParams(); + + foreach ($cookieParams as $name => $value) { + $cookies[] = new Cookie($name, $value); + } + + return new static($cookies); + } + + /** + * Checks if the collection has a cookie with the given name + * + * @param string $name Name of the cookie + * @return bool + */ + public function has($name) + { + $key = mb_strtolower($name); + + return isset($this->cookies[$key]); + } + + /** + * Get a cookie from the collection by name. + * + * @param string $name Name of the cookie to get + * @throws \InvalidArgumentException + * @return Cookie + */ + public function get($name) + { + $key = mb_strtolower($name); + if (isset($this->cookies[$key]) === false) { + throw new InvalidArgumentException(sprintf('Cookie `%s` does not exist', $name)); + } + + return $this->cookies[$key]; + } + + /** + * Current + * + * @return \Cake\Http\Client\Cookie\Cookie $cookie + */ + public function current() + { + return current($this->cookies); + } + + /** + * Key + * + * @return string + */ + public function key() + { + $key = key($this->cookies); + if ($key === null) { + return $key; + } + $cookie = $this->cookies[$key]; + + return $cookie->getName(); + } + + /** + * Next + * + * @return void + */ + public function next() + { + next($this->cookies); + } + + /** + * Valid + * + * @return bool + */ + public function valid() + { + return key($this->cookies) !== null; + } + + /** + * Rewind + * + * @return void + */ + public function rewind() + { + reset($this->cookies); + } +} diff --git a/src/Http/Client/Cookie/ResponseCookies.php b/src/Http/Client/Cookie/ResponseCookies.php new file mode 100644 index 00000000000..f068021597e --- /dev/null +++ b/src/Http/Client/Cookie/ResponseCookies.php @@ -0,0 +1,36 @@ +cookies as $setCookie) { + $header[] = $setCookie->toHeaderValue(); + } + + return $response->withAddedHeader('Set-Cookie', $header); + } +} diff --git a/tests/TestCase/Http/Client/Cookie/CookieTest.php b/tests/TestCase/Http/Client/Cookie/CookieTest.php new file mode 100644 index 00000000000..4fbb00892a3 --- /dev/null +++ b/tests/TestCase/Http/Client/Cookie/CookieTest.php @@ -0,0 +1,108 @@ +assertEquals('Q2FrZQ==.N2Y1ODQ3ZDAzYzQzY2NkYTBlYTkwMmRkZjFmNGI3Mjk4ZWY5ZmExYTA4YmM2ZThjOWFhZWY1Njc4ZDZlMjE4Y/fhI6zv+siabYg0Cnm2j2P51Sghk7WsVxZr94g5fhmkLJ4ve7j54v9r5/vHSIHtog==', $cookie->getValue()); + + $cookie->decrypt('someverysecretkeythatisatleast32charslong'); + $this->assertEquals('cakephp-rocks-and-is-awesome', $cookie->getValue()); + } + + /** + * Testing encrypting the cookie + * + * @return void + */ + public function testEncrypt() + { + $cookie = new Cookie('cakephp', 'cakephp-rocks-and-is-awesome'); + $cookie->encrypt('someverysecretkeythatisatleast32charslong'); + $this->assertNotEmpty('cakephp-rocks-and-is-awesome', $cookie->getValue()); + } + + /** + * Tests the header value + * + * @return void + */ + public function testToHeaderValue() + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $result = $cookie->toHeaderValue(); + $this->assertEquals('cakephp=cakephp-rocks', $result); + + $date = Chronos::createFromFormat('m/d/Y h:m:s', '12/1/2050 12:00:00'); + + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $cookie->setDomain('cakephp.org'); + $cookie->expiresAt($date); + $result = $cookie->toHeaderValue(); + $this->assertEquals('cakephp=cakephp-rocks; expires=Wed, 01-Dec-2049 12:00:00 GMT; domain=cakephp.org', $result); + } + + /** + * Test getting the value from the cookie + * + * @return void + */ + public function testGetValue() + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $result = $cookie->getValue(); + $this->assertEquals('cakephp-rocks', $result); + + $cookie = new Cookie('cakephp', ''); + $result = $cookie->getValue(); + $this->assertEquals('', $result); + } +} diff --git a/tests/TestCase/Http/Client/Cookie/RequestCookiesTest.php b/tests/TestCase/Http/Client/Cookie/RequestCookiesTest.php new file mode 100644 index 00000000000..0b366e469c3 --- /dev/null +++ b/tests/TestCase/Http/Client/Cookie/RequestCookiesTest.php @@ -0,0 +1,63 @@ +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')); + } +} diff --git a/tests/TestCase/Http/Client/Cookie/ResponseCookiesTest.php b/tests/TestCase/Http/Client/Cookie/ResponseCookiesTest.php new file mode 100644 index 00000000000..e1def370295 --- /dev/null +++ b/tests/TestCase/Http/Client/Cookie/ResponseCookiesTest.php @@ -0,0 +1,25 @@ +