Created JWTService and related classes

This commit is contained in:
Alejandro Celaya 2016-08-07 14:44:33 +02:00
parent 1d92e87d50
commit a60080b1ce
11 changed files with 375 additions and 1 deletions

View File

@ -1,5 +1,6 @@
# Application
APP_ENV=
SECRET_KEY=
SHORTENED_URL_SCHEMA=
SHORTENED_URL_HOSTNAME=
SHORTCODE_CHARS=

View File

@ -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",

View File

@ -0,0 +1,10 @@
<?php
return [
'app_options' => [
'name' => 'Shlink',
'version' => '1.1.0',
'secret_key' => env('SECRET_KEY'),
],
];

View File

@ -0,0 +1,6 @@
<?php
return [
'app_options' => [],
];

View File

@ -1,12 +1,15 @@
<?php
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service;
return [
'dependencies' => [
'factories' => [
AppOptions::class => AnnotatedFactory::class,
// Services
Service\UrlShortener::class => AnnotatedFactory::class,
Service\VisitsTracker::class => AnnotatedFactory::class,

View File

@ -0,0 +1,97 @@
<?php
namespace Shlinkio\Shlink\Core\Options;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Zend\Stdlib\AbstractOptions;
class AppOptions extends AbstractOptions
{
use StringUtilsTrait;
/**
* @var string
*/
protected $name = '';
/**
* @var string
*/
protected $version = '1.0';
/**
* @var string
*/
protected $secretKey = '';
/**
* AppOptions constructor.
* @param array|null|\Traversable $options
*
* @Inject({"config.app_options"})
*/
public function __construct($options = null)
{
parent::__construct($options);
}
/**
* @return string
*/
public function getName()
{
return $this->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);
}
}

View File

@ -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;

View File

@ -0,0 +1,110 @@
<?php
namespace Shlinkio\Shlink\Rest\Authentication;
use Firebase\JWT\JWT;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
class JWTService implements JWTServiceInterface
{
/**
* @var AppOptions
*/
private $appOptions;
/**
* JWTService constructor.
* @param AppOptions $appOptions
*/
public function __construct(AppOptions $appOptions)
{
$this->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]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace Shlinkio\Shlink\Rest\Authentication;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
interface JWTServiceInterface
{
const DEFAULT_LIFETIME = 604800; // 1 week
const DEFAULT_ENCRYPTION_ALG = 'HS256';
/**
* 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);
/**
* 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);
/**
* Verifies that certain JWT is valid
*
* @param string $jwt
* @return bool
*/
public function verify($jwt);
/**
* Decodes certain token and returns the payload
*
* @param string $jwt
* @return array
* @throws AuthenticationException If the token has expired
*/
public function getPayload($jwt);
}

View File

@ -9,4 +9,9 @@ class AuthenticationException extends \RuntimeException implements ExceptionInte
{
return new self(sprintf('Invalid credentials. Username -> "%s". Password -> "%s"', $username, $password));
}
public static function expiredJWT(\Exception $prev = null)
{
return new self('The token has expired.', -1, $prev);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Authentication;
use Firebase\JWT\JWT;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class JWTServiceTest extends TestCase
{
/**
* @var JWTService
*/
protected $service;
public function setUp()
{
$this->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');
}
}