From af6fd50a29a530246adc4e28dfd2c9afc57078d4 Mon Sep 17 00:00:00 2001 From: Ben Scholzen Date: Fri, 22 Dec 2017 01:22:30 +0100 Subject: [PATCH] Initial commit --- .coveralls.yml | 2 + .gitignore | 3 + .travis.yml | 41 ++++++ LICENSE | 22 +++ README.md | 102 +++++++++++++ composer.json | 60 ++++++++ doc/example-config.php | 33 +++++ phpcs.xml | 21 +++ phpunit.xml.dist | 17 +++ src/ConfigProvider.php | 27 ++++ src/Cookie.php | 113 +++++++++++++++ src/CookieManager.php | 118 +++++++++++++++ src/CookieManagerInterface.php | 43 ++++++ src/CookieSettings.php | 53 +++++++ src/Exception/ExceptionInterface.php | 10 ++ src/Exception/JsonException.php | 19 +++ src/Factory/CookieManagerFactory.php | 46 ++++++ src/Factory/TokenManagerFactory.php | 25 ++++ src/TokenManager.php | 103 ++++++++++++++ src/TokenManagerInterface.php | 19 +++ test/ConfigProviderTest.php | 27 ++++ test/CookieManagerTest.php | 166 ++++++++++++++++++++++ test/Exception/JsonExceptionTest.php | 21 +++ test/Factory/CookieManagerFactoryTest.php | 73 ++++++++++ test/Factory/TokenManagerFactoryTest.php | 33 +++++ test/TokenManagerTest.php | 82 +++++++++++ 26 files changed, 1279 insertions(+) create mode 100644 .coveralls.yml create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 doc/example-config.php create mode 100644 phpcs.xml create mode 100644 phpunit.xml.dist create mode 100644 src/ConfigProvider.php create mode 100644 src/Cookie.php create mode 100644 src/CookieManager.php create mode 100644 src/CookieManagerInterface.php create mode 100644 src/CookieSettings.php create mode 100644 src/Exception/ExceptionInterface.php create mode 100644 src/Exception/JsonException.php create mode 100644 src/Factory/CookieManagerFactory.php create mode 100644 src/Factory/TokenManagerFactory.php create mode 100644 src/TokenManager.php create mode 100644 src/TokenManagerInterface.php create mode 100644 test/ConfigProviderTest.php create mode 100644 test/CookieManagerTest.php create mode 100644 test/Exception/JsonExceptionTest.php create mode 100644 test/Factory/CookieManagerFactoryTest.php create mode 100644 test/Factory/TokenManagerFactoryTest.php create mode 100644 test/TokenManagerTest.php diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..bc71b62 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1,2 @@ +coverage_clover: clover.xml +json_path: coveralls-upload.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cf9a2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/phpunit.xml +/vendor/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..698945a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,41 @@ +sudo: false + +language: php + +cache: + directories: + - $HOME/.composer/cache + - $HOME/.local + - vendor + +matrix: + fast_finish: true + include: + - php: 7.1 + env: + - EXECUTE_CS_CHECK=true + - EXECUTE_TEST_COVERALLS=true + - PATH="$HOME/.local/bin:$PATH" + - php: nightly + allow_failures: + - php: nightly + +before_install: + - if [[ $EXECUTE_TEST_COVERALLS != 'true' ]]; then phpenv config-rm xdebug.ini || return 0 ; fi + - composer self-update + - if [[ $EXECUTE_TEST_COVERALLS == 'true' ]]; then composer require --dev --no-update php-coveralls/php-coveralls:2.0.0 ; fi + +install: + - travis_retry composer install --no-interaction + - composer info -i + +script: + - if [[ $EXECUTE_TEST_COVERALLS == 'true' ]]; then composer test-coverage ; fi + - if [[ $EXECUTE_TEST_COVERALLS != 'true' ]]; then composer test ; fi + - if [[ $EXECUTE_CS_CHECK == 'true' ]]; then composer cs ; fi + +after_script: + - if [[ $EXECUTE_TEST_COVERALLS == 'true' ]]; then composer coveralls ; fi + +notifications: + email: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c22ee0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2018, Ben Scholzen (DASPRiD) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfabb75 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Pikkuleipa + +[![Build Status](https://travis-ci.org/DASPRiD/Pikkuleipa.svg?branch=master)](https://travis-ci.org/DASPRiD/Pikkuleipa) +[![Coverage Status](https://coveralls.io/repos/github/DASPRiD/Pikkuleipa/badge.svg?branch=master)](https://coveralls.io/github/DASPRiD/Pikkuleipa?branch=master) +[![Latest Stable Version](https://poser.pugx.org/dasprid/pikkuleipa/v/stable)](https://packagist.org/packages/dasprid/pikkuleipa) +[![Total Downloads](https://poser.pugx.org/dasprid/pikkuleipa/downloads)](https://packagist.org/packages/dasprid/pikkuleipa) +[![License](https://poser.pugx.org/dasprid/pikkuleipa/license)](https://packagist.org/packages/dasprid/pikkuleipa) + +Pikkuleipa is a cookie manager for PSR-7 compliant applications, utilizing [JSON Web Tokens](https://jwt.io/) for +security and allowing the handling of multiple independent cookies.authentication middleware embracing PSR-7. + +## Installation + +Install via composer: + +```bash +$ composer require dasprid/pikkuleipa +``` + +## Getting started (for [Expressive](https://github.com/zendframework/zend-expressive)) + +### Import the factory config + +Create a file named `pikkuleipa.global.php` or similar in your autoloading config directory: + +```php +__invoke(); +``` + +This will introduce a few factories, namely you can retrieve the following objects through that: + +- `DASPRiD\Pikkuleipa\CookieManager` through `DASPRiD\Pikkuleipa\CookieManagerInterface` +- `DASPRiD\Pikkuleipa\TokenManager` through `DASPRiD\Pikkuleipa\TokenManagerInterface` + +### Configure Pikkuleipa + +For Pikkuleipa to function, it needs a few configuration variables. Copy the file `doc/example-config.php` and adjust the +values as needed. + +### Using the cookie manager + +The token manager should usually not be of interest to you. The important part is the cookie manager, which you can +either use through the container, if you are using PSR/Container, or by other means. It concretely gives you three +actions you can do, which are setting cookies, getting cookies and expiring cookies. + +#### Setting cookies + +Setting a cookie is really easy. First you either get an existing cookie from the cookie manager or you create a new +one. Then you set that cookie on a PSR-7 response and return the modified response to the user. + +The `setCookie` method takes two additional parameters beside the response and the cookie. The first one is whether the +cookie should expire at the end of the browser session, which defaults to false. The second one defines whether the +`setCookie` call should override a previous `expireCookie` call, which defaults to true. + +```php +get(CookieManagerInterface::class); +$cookie = new Cookie('foo'); +$cookie->set('bar', 'baz'); + +$newResponse = $cookieManager->setCookie($response, $cookie); +``` + +#### Getting cookies + +Getting cookies is also quite simple. When retrieving a cookie, the cookie- and the token manager will verify that the +cookie exists and its contents are legit. If something fails, a new empty cookie instance is returned. + +```php +get(CookieManagerInterface::class); +$cookie = $cookieManager->getCookie($serverRequest, 'foo'); + +echo $cookie->get('bar'); // Outputs: bar +``` + +#### Expiring cookies + +Expiring cookies is just as simple as setting a cookie. You can either expire a cookie by its instance or by name: + +```php +get(CookieManagerInterface::class); +$cookie = $cookieManager->getCookie($serverRequest, 'foo'); + +$newResponse = $cookieManager->expireCookie($cookie); + +// Or: +$newResponse = $cookieManager->expireCookieByName('foo'); +``` + +## About the name + +Pikkuleipa is the Finnish word for "cookie" or "biscuit", nothing fancy here! diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6501b9a --- /dev/null +++ b/composer.json @@ -0,0 +1,60 @@ +{ + "name": "dasprid/pikkuleipa", + "description": "PSR-7 JWT cookie handler", + "type": "library", + "require": { + "php": "^7.1", + "lcobucci/jwt": "^3.2", + "psr/http-message": "^1.0", + "dflydev/fig-cookies": "^1.0", + "cultuurnet/clock": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.5", + "psr/container": "^1.0", + "dasprid/treereader": "^1.3", + "zendframework/zend-diactoros": "^1.3", + "squizlabs/php_codesniffer": "^2.7" + }, + "suggest": { + "psr/container": "For using the supplied factories", + "dasprid/treereader": "For using the supplied factories" + }, + "license": "BSD-2-Clause", + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "homepage": "https://dasprids.de/", + "email": "mail@dasprids.de" + } + ], + "keywords": [ + "jwt", + "cookie", + "session", + "http", + "psr", + "psr-7" + ], + "autoload": { + "psr-4": { + "DASPRiD\\Pikkuleipa\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "DASPRiD\\PikkuleipaTest\\": "test/" + } + }, + "scripts": { + "check": [ + "@cs", + "@test" + ], + "coveralls": "php-coveralls", + "cs": "phpcs", + "cs-fix": "phpcbf", + "test": "phpunit", + "test-coverage": "phpunit --coverage-clover clover.xml" + } +} diff --git a/doc/example-config.php b/doc/example-config.php new file mode 100644 index 0000000..750d555 --- /dev/null +++ b/doc/example-config.php @@ -0,0 +1,33 @@ + [ + 'default_cookie_settings' => [ + // Path which the cookie applies to + 'path' => '/', + + // Whether the cookie is limited to HTTPS + 'secure' => true, + + // Lifetime of the cookie, here 30 days + 'lifetime' => 2592000, + ], + + 'cookie_settings' => [ + // Here you can configure all the different cookies you are using + 'some_cookie_name' => [ + 'path' => '/', + 'secure' => true, + 'lifetime' => 60 + ], + ], + + 'token' => [ + // Signer used for signing and verification + 'signer_class' => Lcobucci\JWT\Signer\Rsa\Sha256::class, + + // Signature and verification keys. See: https://github.com/lcobucci/jwt#token-signature + 'signature_key' => '', + 'verification_key' => '', + ], + ], +]; diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..3134b1f --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,21 @@ + + + Pikkuleipa coding standard + + + + + + + + + + + + + + + + src + test + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..fb89fe8 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + ./test + + + + + + src + + + diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php new file mode 100644 index 0000000..7643a2f --- /dev/null +++ b/src/ConfigProvider.php @@ -0,0 +1,27 @@ + $this->getDependencyConfig(), + ]; + } + + public function getDependencyConfig() : array + { + return [ + 'factories' => [ + CookieManagerInterface::class => CookieManagerFactory::class, + TokenManagerInterface::class => TokenManagerFactory::class, + ], + ]; + } +} diff --git a/src/Cookie.php b/src/Cookie.php new file mode 100644 index 0000000..cc25b9d --- /dev/null +++ b/src/Cookie.php @@ -0,0 +1,113 @@ +name = $name; + $this->issuedAt = $issuedAt ?: new DateTimeImmutable(); + } + + /** + * Creates a new cookie instance from JSON encoded data. + * + * This is primarily used by the `TokenManager` to unserialize a cookie from a JWT token. + * + * @throws JsonException When an error occurs during decoding + */ + public static function fromJson(string $name, DateTimeImmutable $issuedAt, string $json) : self + { + $cookie = new self($name, $issuedAt); + $cookie->data = json_decode($json, true); + + if (! is_array($cookie->data)) { + throw JsonException::fromJsonDecodeError(json_last_error_msg()); + } + + return $cookie; + } + + /** + * Serializes the cookie data as JSON. + * + * @throws JsonException When an error occurs during encoding + */ + public function toJson() : string + { + $json = json_encode($this->data); + + if (false === $json) { + throw JsonException::fromJsonEncodeError(json_last_error_msg()); + } + + return $json; + } + + /** + * Returns the name of the cookie. + */ + public function getName() : string + { + return $this->name; + } + + /** + * Returns when the cookie what issued. + */ + public function getIssuedAt() : DateTimeImmutable + { + return $this->issuedAt; + } + + /** + * Returns a value from the cookie, or null if it doesn't exist. + * + * @return mixed + */ + public function get(string $name) + { + return $this->data[$name] ?? null; + } + + /** + * Sets a value in the cookie. + * + * The value can be any scalar, array or object, which implements the `JsonSerializable` interface. Other values + * will lead to an exception when trying to encode it for the response. + * + * @param mixed $value + */ + public function set(string $name, $value) : void + { + $this->data[$name] = $value; + } + + /** + * Removes a value from the cookie. + */ + public function remove(string $name) : void + { + unset($this->data[$name]); + } +} diff --git a/src/CookieManager.php b/src/CookieManager.php new file mode 100644 index 0000000..6738b96 --- /dev/null +++ b/src/CookieManager.php @@ -0,0 +1,118 @@ +defaultCookieSettings = $defaultCookieSettings; + $this->tokenManager = $tokenManager; + $this->clock = $clock ?: new SystemClock(new DateTimeZone('UTC')); + } + + public function withCookieSettings(string $cookieName, CookieSettings $cookieSettings) : self + { + $cookieManager = new self($this->defaultCookieSettings, $this->tokenManager, $this->clock); + $cookieManager->cookieSettings = $this->cookieSettings; + $cookieManager->cookieSettings[$cookieName] = $cookieSettings; + return $cookieManager; + } + + public function getCookie(ServerRequestInterface $request, string $cookieName) : Cookie + { + $requestCookie = FigRequestCookies::get($request, $cookieName); + $cookieValue = $requestCookie->getValue(); + + if (null === $cookieValue) { + return new Cookie($cookieName); + } + + $cookie = $this->tokenManager->parseSignedToken($cookieValue); + + if (null === $cookie || $cookie->getName() !== $cookieName) { + return new Cookie($cookieName); + } + + return $cookie; + } + + public function setCookie( + ResponseInterface $response, + Cookie $cookie, + bool $endWithSession = false, + bool $overwriteExpireCookie = true + ) : ResponseInterface { + $cookieName = $cookie->getName(); + + if (! $overwriteExpireCookie && 1 === FigResponseCookies::get($response, $cookieName)->getExpires()) { + return $response; + } + + $cookieSettings = $this->cookieSettings[$cookieName] ?? $this->defaultCookieSettings; + $currentTimestamp = $this->clock->getDateTime()->getTimestamp(); + $setCookie = SetCookie::create($cookieName) + ->withHttpOnly(true) + ->withPath($cookieSettings->getPath()) + ->withExpires($endWithSession ? null : $currentTimestamp + $cookieSettings->getLifetime()) + ->withSecure($cookieSettings->isSecure()); + + return FigResponseCookies::set( + $response, + $setCookie->withValue( + $this->tokenManager->getSignedToken($cookie, $endWithSession ? null : $cookieSettings->getLifetime()) + ) + ); + } + + public function expireCookie(ResponseInterface $response, Cookie $cookie) : ResponseInterface + { + return $this->expireCookieByName($response, $cookie->getName()); + } + + public function expireCookieByName(ResponseInterface $response, string $cookieName) : ResponseInterface + { + $cookieSettings = $this->cookieSettings[$cookieName] ?? $this->defaultCookieSettings; + $setCookie = SetCookie::create($cookieName) + ->withHttpOnly(true) + ->withPath($cookieSettings->getPath()) + ->withExpires(1) + ->withSecure($cookieSettings->isSecure()) + ->withValue(''); + + return FigResponseCookies::set($response, $setCookie); + } +} diff --git a/src/CookieManagerInterface.php b/src/CookieManagerInterface.php new file mode 100644 index 0000000..80a64c1 --- /dev/null +++ b/src/CookieManagerInterface.php @@ -0,0 +1,43 @@ +path = $path; + $this->secure = $secure; + $this->lifetime = $lifetime; + } + + /** + * Gets the path for the cookie. + */ + public function getPath() : string + { + return $this->path; + } + + /** + * Returns whether the cookie should be marked as HTTPS only. + */ + public function isSecure() : bool + { + return $this->secure; + } + + /** + * Gets the lifetime of the cookie in seconds. + */ + public function getLifetime() : int + { + return $this->lifetime; + } +} diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php new file mode 100644 index 0000000..88ff948 --- /dev/null +++ b/src/Exception/ExceptionInterface.php @@ -0,0 +1,10 @@ +get('config')))->getChildren('pikkuleipa'); + + $cookieManager = new CookieManager( + $this->createCookieSettings($config->getChildren('default_cookie_settings')), + $container->get(TokenManagerInterface::class) + ); + + if (! $config->hasKey('cookie_settings')) { + return $cookieManager; + } + + foreach ($config->getChildren('cookie_settings') as $cookieSettings) { + $cookieManager = $cookieManager->withCookieSettings( + $cookieSettings->getKey(), + $this->createCookieSettings($cookieSettings->getChildren()) + ); + } + + return $cookieManager; + } + + private function createCookieSettings(TreeReader $config) : CookieSettings + { + return new CookieSettings( + $config->getString('path'), + $config->getBool('secure'), + $config->getInt('lifetime') + ); + } +} diff --git a/src/Factory/TokenManagerFactory.php b/src/Factory/TokenManagerFactory.php new file mode 100644 index 0000000..891b67e --- /dev/null +++ b/src/Factory/TokenManagerFactory.php @@ -0,0 +1,25 @@ +get('config')))->getChildren('pikkuleipa')->getChildren('token'); + + $signerClass = $config->getString('signer_class'); + + return new TokenManager( + new $signerClass(), + $config->getString('signature_key'), + $config->getString('verification_key') + ); + } +} diff --git a/src/TokenManager.php b/src/TokenManager.php new file mode 100644 index 0000000..b39a5f7 --- /dev/null +++ b/src/TokenManager.php @@ -0,0 +1,103 @@ +signer = $signer; + $this->signatureKey = $signatureKey; + $this->verificationKey = $verificationKey; + $this->tokenParser = $tokenParser ?: new Parser(); + $this->clock = $clock ?: new SystemClock(new DateTimeZone('UTC')); + } + + public function getSignedToken(Cookie $cookie, ?int $lifetime = null) : string + { + $currentTimestamp = $this->clock->getDateTime()->getTimestamp(); + $builder = (new Builder()) + ->setIssuedAt($currentTimestamp) + ->setSubject($cookie->getName()) + ->set('dat', $cookie->toJson()); + + if (null !== $lifetime) { + $builder->setExpiration($currentTimestamp + $lifetime); + } + + return (string) $builder->sign($this->signer, $this->signatureKey)->getToken(); + } + + public function parseSignedToken(string $serializedToken) : ?Cookie + { + try { + $token = $this->tokenParser->parse($serializedToken); + } catch (Exception $e) { + return null; + } + + if (! $token->validate(new ValidationData($this->clock->getDateTime()->getTimestamp()))) { + return null; + } + + if (! $token->verify($this->signer, $this->verificationKey)) { + return null; + } + + if (! $token->hasClaim('sub') || ! $token->hasClaim('iat') || ! $token->hasClaim('dat')) { + return null; + } + + try { + return Cookie::fromJson( + $token->getClaim('sub'), + new DateTimeImmutable('@' . $token->getClaim('iat')), + $token->getClaim('dat') + ); + } catch (JsonException $e) { + return null; + } + } +} diff --git a/src/TokenManagerInterface.php b/src/TokenManagerInterface.php new file mode 100644 index 0000000..408d530 --- /dev/null +++ b/src/TokenManagerInterface.php @@ -0,0 +1,19 @@ +assertSame([ + 'dependencies' => (new ConfigProvider())->getDependencyConfig(), + ], (new ConfigProvider())->__invoke()); + } + + public function testGetDependencyConfig() : void + { + $dependencyConfig = (new ConfigProvider())->getDependencyConfig(); + $this->assertArrayHasKey('factories', $dependencyConfig); + $this->assertArrayHasKey(CookieManagerInterface::class, $dependencyConfig['factories']); + $this->assertArrayHasKey(TokenManagerInterface::class, $dependencyConfig['factories']); + } +} diff --git a/test/CookieManagerTest.php b/test/CookieManagerTest.php new file mode 100644 index 0000000..0ba8e88 --- /dev/null +++ b/test/CookieManagerTest.php @@ -0,0 +1,166 @@ +prophesize(TokenManagerInterface::class); + $tokenManager->getSignedToken($cookie, 100)->willReturn('bar'); + $cookieManager = $this->createCookieManager($tokenManager->reveal()); + + $originalResponse = new EmptyResponse(); + $newResponse = $cookieManager->setCookie( + $originalResponse, + $cookie, + false + ); + + $this->assertSame([ + 'foo=bar; Path=/foo; Expires=Thu, 01 Jan 1970 00:03:20 GMT; Secure; HttpOnly', + ], $newResponse->getHeader('Set-Cookie')); + } + + public function testSetNonSecureCookie() + { + $cookie = new Cookie('foo'); + $tokenManager = $this->prophesize(TokenManagerInterface::class); + $tokenManager->getSignedToken($cookie, 100)->willReturn('bar'); + $cookieManager = $this->createCookieManager($tokenManager->reveal(), false); + + $originalResponse = new EmptyResponse(); + $newResponse = $cookieManager->setCookie( + $originalResponse, + $cookie, + false + ); + + $this->assertSame([ + 'foo=bar; Path=/foo; Expires=Thu, 01 Jan 1970 00:03:20 GMT; HttpOnly', + ], $newResponse->getHeader('Set-Cookie')); + } + + public function testSetCookieExpiringEndOfSession() + { + $cookie = new Cookie('foo'); + $tokenManager = $this->prophesize(TokenManagerInterface::class); + $tokenManager->getSignedToken($cookie, null)->willReturn('bar'); + $cookieManager = $this->createCookieManager($tokenManager->reveal(), false); + + $originalResponse = new EmptyResponse(); + $newResponse = $cookieManager->setCookie( + $originalResponse, + $cookie, + true + ); + + $this->assertSame([ + 'foo=bar; Path=/foo; HttpOnly', + ], $newResponse->getHeader('Set-Cookie')); + } + + public function testExpireCookieIsNotOverwrittenWithSetFlag() + { + $cookie = new Cookie('foo'); + $tokenManager = $this->prophesize(TokenManagerInterface::class); + $tokenManager->getSignedToken($cookie, 100)->willReturn('bar'); + $cookieManager = $this->createCookieManager($tokenManager->reveal(), false); + + $originalResponse = new EmptyResponse(); + $expireResponse = $cookieManager->expireCookie($originalResponse, $cookie); + $newResponse = $cookieManager->setCookie( + $expireResponse, + $cookie, + false, + false + ); + + $this->assertSame([ + 'foo=; Path=/foo; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly', + ], $newResponse->getHeader('Set-Cookie')); + } + + public function testSecureExpireCookie() + { + $cookie = new Cookie('foo'); + $tokenManager = $this->prophesize(TokenManagerInterface::class); + $tokenManager->getSignedToken($cookie, 100)->willReturn('bar'); + $cookieManager = $this->createCookieManager($tokenManager->reveal(), true); + + $originalResponse = new EmptyResponse(); + $newResponse = $cookieManager->expireCookie($originalResponse, $cookie); + + $this->assertSame([ + 'foo=; Path=/foo; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Secure; HttpOnly', + ], $newResponse->getHeader('Set-Cookie')); + } + + public function testNonSecureExpireTokenCookie() + { + $cookie = new Cookie('foo'); + $tokenManager = $this->prophesize(TokenManagerInterface::class); + $tokenManager->getSignedToken($cookie, 100)->willReturn('bar'); + $cookieManager = $this->createCookieManager($tokenManager->reveal(), false); + + $originalResponse = new EmptyResponse(); + $newResponse = $cookieManager->expireCookieByName($originalResponse, 'foo'); + + $this->assertSame([ + 'foo=; Path=/foo; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly', + ], $newResponse->getHeader('Set-Cookie')); + } + + public function testGetCookieWithExistentToken() + { + $cookie = new Cookie('foo'); + $cookie->set('bar', 'baz'); + $tokenManager = $this->prophesize(TokenManagerInterface::class); + $tokenManager->parseSignedToken('bat')->willReturn($cookie); + $cookieManager = $this->createCookieManager($tokenManager->reveal()); + + $request = $this->prophesize(ServerRequestInterface::class); + $request->getHeaderLine('Cookie')->willReturn('foo=bat'); + + $this->assertSame($cookie, $cookieManager->getCookie($request->reveal(), 'foo')); + } + + public function testGetCookieWithNonExistentToken() + { + $tokenManager = $this->prophesize(TokenManagerInterface::class); + $tokenManager->parseSignedToken('bat')->willReturn(null); + $cookieManager = $this->createCookieManager($tokenManager->reveal()); + + $request = $this->prophesize(ServerRequestInterface::class); + $request->getHeaderLine('Cookie')->willReturn('foo=bat'); + + $this->assertSame('foo', $cookieManager->getCookie($request->reveal(), 'foo')->getName()); + } + + private function createCookieManager(TokenManagerInterface $tokenManager, bool $secure = true) : CookieManager + { + if (null === $tokenManager) { + $tokenManager = $this->prophesize(TokenManagerInterface::class)->reveal(); + } + + $clock = new FrozenClock(new DateTimeImmutable('@100')); + + return new CookieManager( + new CookieSettings('/foo', $secure, 100), + $tokenManager, + $clock + ); + } +} diff --git a/test/Exception/JsonExceptionTest.php b/test/Exception/JsonExceptionTest.php new file mode 100644 index 0000000..b62bed3 --- /dev/null +++ b/test/Exception/JsonExceptionTest.php @@ -0,0 +1,21 @@ +assertSame('Could not decode JSON string: foo', $exception->getMessage()); + } + + public function testFromJsonEncodeError() : void + { + $exception = JsonException::fromJsonEncodeError('foo'); + $this->assertSame('Could not encode data to JSON: foo', $exception->getMessage()); + } +} diff --git a/test/Factory/CookieManagerFactoryTest.php b/test/Factory/CookieManagerFactoryTest.php new file mode 100644 index 0000000..058f311 --- /dev/null +++ b/test/Factory/CookieManagerFactoryTest.php @@ -0,0 +1,73 @@ +prophesize(ContainerInterface::class); + $container->get('config')->willReturn([ + 'pikkuleipa' => [ + 'default_cookie_settings' => [ + 'path' => '/', + 'secure' => true, + 'lifetime' => 100, + ], + ], + ]); + $tokenManager = $this->prophesize(TokenManagerInterface::class)->reveal(); + $container->get(TokenManagerInterface::class)->willReturn($tokenManager); + + $factory = new CookieManagerFactory(); + $cookieManager = $factory($container->reveal()); + + $this->assertAttributeEquals(new CookieSettings('/', true, 100), 'defaultCookieSettings', $cookieManager); + $this->assertAttributeSame($tokenManager, 'tokenManager', $cookieManager); + } + + public function testSpecificCookieSettings() : void + { + $container = $this->prophesize(ContainerInterface::class); + $container->get('config')->willReturn([ + 'pikkuleipa' => [ + 'default_cookie_settings' => [ + 'path' => '/', + 'secure' => true, + 'lifetime' => 100, + ], + 'cookie_settings' => [ + 'foo' => [ + 'path' => '/foo', + 'secure' => true, + 'lifetime' => 100, + ], + 'bar' => [ + 'path' => '/bar', + 'secure' => false, + 'lifetime' => 200, + ], + ], + ], + ]); + $tokenManager = $this->prophesize(TokenManagerInterface::class)->reveal(); + $container->get(TokenManagerInterface::class)->willReturn($tokenManager); + + $factory = new CookieManagerFactory(); + $cookieManager = $factory($container->reveal()); + + $this->assertAttributeEquals(new CookieSettings('/', true, 100), 'defaultCookieSettings', $cookieManager); + $this->assertAttributeEquals([ + 'foo' => new CookieSettings('/foo', true, 100), + 'bar' => new CookieSettings('/bar', false, 200), + ], 'cookieSettings', $cookieManager); + $this->assertAttributeSame($tokenManager, 'tokenManager', $cookieManager); + } +} diff --git a/test/Factory/TokenManagerFactoryTest.php b/test/Factory/TokenManagerFactoryTest.php new file mode 100644 index 0000000..c2434f9 --- /dev/null +++ b/test/Factory/TokenManagerFactoryTest.php @@ -0,0 +1,33 @@ +prophesize(ContainerInterface::class); + $container->get('config')->willReturn([ + 'pikkuleipa' => [ + 'token' => [ + 'signer_class' => Sha256::class, + 'signature_key' => 'foo', + 'verification_key' => 'bar', + ], + ], + ]); + + $factory = new TokenManagerFactory(); + $tokenManager = $factory($container->reveal()); + + $this->assertAttributeInstanceOf(Sha256::class, 'signer', $tokenManager); + $this->assertAttributeSame('foo', 'signatureKey', $tokenManager); + $this->assertAttributeSame('bar', 'verificationKey', $tokenManager); + } +} diff --git a/test/TokenManagerTest.php b/test/TokenManagerTest.php new file mode 100644 index 0000000..f45fd8d --- /dev/null +++ b/test/TokenManagerTest.php @@ -0,0 +1,82 @@ +set('bar', 'baz'); + + $clock = new FrozenClock(new DateTimeImmutable('@100')); + $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo', new Parser(), $clock); + $token = $tokenManager->getSignedToken($cookie, 100); + + $data = json_decode(base64_decode(explode('.', $token)[1]), true); + $this->assertSame('foo', $data['sub']); + $this->assertSame(100, $data['iat']); + $this->assertSame(200, $data['exp']); + $this->assertSame(['bar' => 'baz'], json_decode($data['dat'], true)); + } + + public function testGetSignedTokenWithoutExpire() + { + $cookie = new Cookie('foo'); + $cookie->set('bar', 'baz'); + + $clock = new FrozenClock(new DateTimeImmutable('@100')); + $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo', new Parser(), $clock); + $token = $tokenManager->getSignedToken($cookie); + + $data = json_decode(base64_decode(explode('.', $token)[1]), true); + $this->assertArrayNotHasKey('exp', $data); + } + + public function testParseProperlySignedToken() + { + $cookie = new Cookie('foo'); + $cookie->set('bar', 'baz'); + + $clock = new FrozenClock(new DateTimeImmutable('@100')); + $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo', new Parser(), $clock); + $cookie = $tokenManager->parseSignedToken((string) $tokenManager->getSignedToken($cookie, 100)); + + $this->assertEquals(new DateTimeImmutable('@100'), $cookie->getIssuedAt()); + $this->assertSame('baz', $cookie->get('bar')); + $this->assertSame('foo', $cookie->getName()); + } + + public function testParseMalformedToken() + { + $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo'); + $this->assertNull($tokenManager->parseSignedToken('foo')); + } + + public function testParseExpiredToken() + { + $clock = new FrozenClock(new DateTimeImmutable('@100')); + $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo', new Parser(), $clock); + $this->assertNull( + $tokenManager->parseSignedToken((string) $tokenManager->getSignedToken(new Cookie('foo'), -1)) + ); + } + + public function testIllegalToken() + { + $clock = new FrozenClock(new DateTimeImmutable('@100')); + $tokenManager = new TokenManager(new Sha256(), 'foo', 'bar', new Parser(), $clock); + $this->assertNull( + $tokenManager->parseSignedToken((string) $tokenManager->getSignedToken(new Cookie('foo'), 1)) + ); + } +}