From a60080b1ce6f7835716822d5936999831af4d411 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 14:44:33 +0200 Subject: [PATCH] Created JWTService and related classes --- .env.dist | 1 + composer.json | 3 +- config/autoload/app_options.global.php | 10 ++ module/Core/config/app_options.config.php | 6 + module/Core/config/dependencies.config.php | 3 + module/Core/src/Options/AppOptions.php | 97 +++++++++++++++ module/Rest/src/Action/AuthenticateAction.php | 1 + module/Rest/src/Authentication/JWTService.php | 110 ++++++++++++++++++ .../Authentication/JWTServiceInterface.php | 47 ++++++++ .../src/Exception/AuthenticationException.php | 5 + .../test/Authentication/JWTServiceTest.php | 93 +++++++++++++++ 11 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 config/autoload/app_options.global.php create mode 100644 module/Core/config/app_options.config.php create mode 100644 module/Core/src/Options/AppOptions.php create mode 100644 module/Rest/src/Authentication/JWTService.php create mode 100644 module/Rest/src/Authentication/JWTServiceInterface.php create mode 100644 module/Rest/test/Authentication/JWTServiceTest.php diff --git a/.env.dist b/.env.dist index 9b175618..d56f522f 100644 --- a/.env.dist +++ b/.env.dist @@ -1,5 +1,6 @@ # Application APP_ENV= +SECRET_KEY= SHORTENED_URL_SCHEMA= SHORTENED_URL_HOSTNAME= SHORTCODE_CHARS= diff --git a/composer.json b/composer.json index 829bb949..72391371 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "acelaya/zsm-annotated-services": "^0.2.0", "doctrine/orm": "^2.5", "guzzlehttp/guzzle": "^6.2", - "symfony/console": "^3.0" + "symfony/console": "^3.0", + "firebase/php-jwt": "^4.0" }, "require-dev": { "phpunit/phpunit": "^5.0", diff --git a/config/autoload/app_options.global.php b/config/autoload/app_options.global.php new file mode 100644 index 00000000..4db642ce --- /dev/null +++ b/config/autoload/app_options.global.php @@ -0,0 +1,10 @@ + [ + 'name' => 'Shlink', + 'version' => '1.1.0', + 'secret_key' => env('SECRET_KEY'), + ], + +]; diff --git a/module/Core/config/app_options.config.php b/module/Core/config/app_options.config.php new file mode 100644 index 00000000..bf224541 --- /dev/null +++ b/module/Core/config/app_options.config.php @@ -0,0 +1,6 @@ + [], + +]; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 9bcacbfa..27983069 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -1,12 +1,15 @@ [ 'factories' => [ + AppOptions::class => AnnotatedFactory::class, + // Services Service\UrlShortener::class => AnnotatedFactory::class, Service\VisitsTracker::class => AnnotatedFactory::class, diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php new file mode 100644 index 00000000..6ee1322c --- /dev/null +++ b/module/Core/src/Options/AppOptions.php @@ -0,0 +1,97 @@ +name; + } + + /** + * @param string $name + * @return $this + */ + protected function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * @return string + */ + public function getVersion() + { + return $this->version; + } + + /** + * @param string $version + * @return $this + */ + protected function setVersion($version) + { + $this->version = $version; + return $this; + } + + /** + * @return mixed + */ + public function getSecretKey() + { + return $this->secretKey; + } + + /** + * @param mixed $secretKey + * @return $this + */ + protected function setSecretKey($secretKey) + { + $this->secretKey = $secretKey; + return $this; + } + + /** + * @return string + */ + public function __toString() + { + return sprintf('%s:v%s', $this->name, $this->version); + } +} diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 0fcac3f9..093e935f 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -2,6 +2,7 @@ namespace Shlinkio\Shlink\Rest\Action; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Firebase\JWT\JWT; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Rest\Service\ApiKeyService; diff --git a/module/Rest/src/Authentication/JWTService.php b/module/Rest/src/Authentication/JWTService.php new file mode 100644 index 00000000..bc1647c2 --- /dev/null +++ b/module/Rest/src/Authentication/JWTService.php @@ -0,0 +1,110 @@ +appOptions = $appOptions; + } + + /** + * Creates a new JSON web token por provided API key + * + * @param ApiKey $apiKey + * @param int $lifetime + * @return string + */ + public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME) + { + $currentTimestamp = time(); + + return $this->encode([ + 'iss' => $this->appOptions->__toString(), + 'iat' => $currentTimestamp, + 'exp' => $currentTimestamp + $lifetime, + 'sub' => 'auth', + 'key' => $apiKey->getId(), // The ID is opaque. Returning the key would be insecure + ]); + } + + /** + * Refreshes a token and returns it with the new expiration + * + * @param string $jwt + * @param int $lifetime + * @return string + * @throws AuthenticationException If the token has expired + */ + public function refresh($jwt, $lifetime = self::DEFAULT_LIFETIME) + { + $payload = $this->getPayload($jwt); + $payload['exp'] = time() + $lifetime; + return $this->encode($payload); + } + + /** + * Verifies that certain JWT is valid + * + * @param string $jwt + * @return bool + */ + public function verify($jwt) + { + try { + // If no exception is thrown while decoding the token, it is considered valid + $this->decode($jwt); + return true; + } catch (\UnexpectedValueException $e) { + return false; + } + } + + /** + * Decodes certain token and returns the payload + * + * @param string $jwt + * @return array + * @throws AuthenticationException If the token has expired + */ + public function getPayload($jwt) + { + try { + return $this->decode($jwt); + } catch (\UnexpectedValueException $e) { + throw AuthenticationException::expiredJWT($e); + } + } + + /** + * @param array $data + * @return string + */ + protected function encode(array $data) + { + return JWT::encode($data, $this->appOptions->getSecretKey(), self::DEFAULT_ENCRYPTION_ALG); + } + + /** + * @param $jwt + * @return array + */ + protected function decode($jwt) + { + return (array) JWT::decode($jwt, $this->appOptions->getSecretKey(), [self::DEFAULT_ENCRYPTION_ALG]); + } +} diff --git a/module/Rest/src/Authentication/JWTServiceInterface.php b/module/Rest/src/Authentication/JWTServiceInterface.php new file mode 100644 index 00000000..278e6c67 --- /dev/null +++ b/module/Rest/src/Authentication/JWTServiceInterface.php @@ -0,0 +1,47 @@ + "%s". Password -> "%s"', $username, $password)); } + + public static function expiredJWT(\Exception $prev = null) + { + return new self('The token has expired.', -1, $prev); + } } diff --git a/module/Rest/test/Authentication/JWTServiceTest.php b/module/Rest/test/Authentication/JWTServiceTest.php new file mode 100644 index 00000000..ede0b6c6 --- /dev/null +++ b/module/Rest/test/Authentication/JWTServiceTest.php @@ -0,0 +1,93 @@ +service = new JWTService(new AppOptions([ + 'name' => 'ShlinkTest', + 'version' => '10000.3.1', + 'secret_key' => 'foo', + ])); + } + + /** + * @test + */ + public function tokenIsProperlyCreated() + { + $id = 34; + $token = $this->service->create((new ApiKey())->setId($id)); + $payload = (array) JWT::decode($token, 'foo', [JWTService::DEFAULT_ENCRYPTION_ALG]); + $this->assertGreaterThanOrEqual($payload['iat'], time()); + $this->assertGreaterThan(time(), $payload['exp']); + $this->assertEquals($id, $payload['key']); + $this->assertEquals('auth', $payload['sub']); + $this->assertEquals('ShlinkTest:v10000.3.1', $payload['iss']); + } + + /** + * @test + */ + public function refreshIncreasesExpiration() + { + $originalLifetime = 10; + $newLifetime = 30; + $originalPayload = ['exp' => time() + $originalLifetime]; + $token = JWT::encode($originalPayload, 'foo'); + $newToken = $this->service->refresh($token, $newLifetime); + $newPayload = (array) JWT::decode($newToken, 'foo', [JWTService::DEFAULT_ENCRYPTION_ALG]); + + $this->assertGreaterThan($originalPayload['exp'], $newPayload['exp']); + } + + /** + * @test + */ + public function verifyReturnsTrueWhenTheTokenIsCorrect() + { + $this->assertTrue($this->service->verify(JWT::encode([], 'foo'))); + } + + /** + * @test + */ + public function verifyReturnsFalseWhenTheTokenIsCorrect() + { + $this->assertFalse($this->service->verify('invalidToken')); + } + + /** + * @test + */ + public function getPayloadWorksWithCorrectTokens() + { + $originalPayload = [ + 'exp' => time() + 10, + 'sub' => 'testing', + ]; + $token = JWT::encode($originalPayload, 'foo'); + $this->assertEquals($originalPayload, $this->service->getPayload($token)); + } + + /** + * @test + * @expectedException \Shlinkio\Shlink\Rest\Exception\AuthenticationException + */ + public function getPayloadThrowsExceptionWithIncorrectTokens() + { + $this->service->getPayload('invalidToken'); + } +}