mirror of
https://github.com/shlinkio/shlink.git
synced 2026-10-12 21:31:58 -05:00
Created JWTService and related classes
This commit is contained in:
1 parent
1d92e87d50
commit
a60080b1ce
11 files changed
+375
-1
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
# Application
|
# Application
|
||||||
APP_ENV=
|
APP_ENV=
|
||||||
|
SECRET_KEY=
|
||||||
SHORTENED_URL_SCHEMA=
|
SHORTENED_URL_SCHEMA=
|
||||||
SHORTENED_URL_HOSTNAME=
|
SHORTENED_URL_HOSTNAME=
|
||||||
SHORTCODE_CHARS=
|
SHORTCODE_CHARS=
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,8 @@
|
|||||||
"acelaya/zsm-annotated-services": "^0.2.0",
|
"acelaya/zsm-annotated-services": "^0.2.0",
|
||||||
"doctrine/orm": "^2.5",
|
"doctrine/orm": "^2.5",
|
||||||
"guzzlehttp/guzzle": "^6.2",
|
"guzzlehttp/guzzle": "^6.2",
|
||||||
"symfony/console": "^3.0"
|
"symfony/console": "^3.0",
|
||||||
|
"firebase/php-jwt": "^4.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^5.0",
|
"phpunit/phpunit": "^5.0",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
|
||||||
|
'app_options' => [
|
||||||
|
'name' => 'Shlink',
|
||||||
|
'version' => '1.1.0',
|
||||||
|
'secret_key' => env('SECRET_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
|
||||||
|
'app_options' => [],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
|
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
|
||||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||||
|
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||||
use Shlinkio\Shlink\Core\Service;
|
use Shlinkio\Shlink\Core\Service;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'dependencies' => [
|
'dependencies' => [
|
||||||
'factories' => [
|
'factories' => [
|
||||||
|
AppOptions::class => AnnotatedFactory::class,
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
Service\UrlShortener::class => AnnotatedFactory::class,
|
Service\UrlShortener::class => AnnotatedFactory::class,
|
||||||
Service\VisitsTracker::class => AnnotatedFactory::class,
|
Service\VisitsTracker::class => AnnotatedFactory::class,
|
||||||
|
|||||||
@@ -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;
|
namespace Shlinkio\Shlink\Rest\Action;
|
||||||
|
|
||||||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user