mirror of
https://github.com/shlinkio/shlink.git
synced 2024-12-22 15:13:59 -06:00
Created JWTService and related classes
This commit is contained in:
parent
1d92e87d50
commit
a60080b1ce
@ -1,5 +1,6 @@
|
||||
# Application
|
||||
APP_ENV=
|
||||
SECRET_KEY=
|
||||
SHORTENED_URL_SCHEMA=
|
||||
SHORTENED_URL_HOSTNAME=
|
||||
SHORTCODE_CHARS=
|
||||
|
@ -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",
|
||||
|
10
config/autoload/app_options.global.php
Normal file
10
config/autoload/app_options.global.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
return [
|
||||
|
||||
'app_options' => [
|
||||
'name' => 'Shlink',
|
||||
'version' => '1.1.0',
|
||||
'secret_key' => env('SECRET_KEY'),
|
||||
],
|
||||
|
||||
];
|
6
module/Core/config/app_options.config.php
Normal file
6
module/Core/config/app_options.config.php
Normal file
@ -0,0 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
|
||||
'app_options' => [],
|
||||
|
||||
];
|
@ -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,
|
||||
|
97
module/Core/src/Options/AppOptions.php
Normal file
97
module/Core/src/Options/AppOptions.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
110
module/Rest/src/Authentication/JWTService.php
Normal file
110
module/Rest/src/Authentication/JWTService.php
Normal 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]);
|
||||
}
|
||||
}
|
47
module/Rest/src/Authentication/JWTServiceInterface.php
Normal file
47
module/Rest/src/Authentication/JWTServiceInterface.php
Normal 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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
93
module/Rest/test/Authentication/JWTServiceTest.php
Normal file
93
module/Rest/test/Authentication/JWTServiceTest.php
Normal 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');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user