mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
commit
0a4f8c3b0a
44 changed files with 1572 additions and 436 deletions
|
@ -1,5 +1,6 @@
|
|||
# Application
|
||||
APP_ENV=
|
||||
SECRET_KEY=
|
||||
SHORTENED_URL_SCHEMA=
|
||||
SHORTENED_URL_HOSTNAME=
|
||||
SHORTCODE_CHARS=
|
||||
|
@ -12,7 +13,3 @@ CLI_LOCALE=
|
|||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
|
||||
# Rest authentication
|
||||
REST_USER=
|
||||
REST_PASSWORD=
|
||||
|
|
|
@ -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'),
|
||||
],
|
||||
|
||||
];
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
return [
|
||||
|
||||
'database' => [
|
||||
'driver' => 'pdo_mysql',
|
||||
'user' => env('DB_USER'),
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'dbname' => env('DB_NAME', 'shlink'),
|
||||
'charset' => 'utf8',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8'
|
||||
],
|
||||
],
|
||||
|
||||
];
|
20
config/autoload/entity-manager.global.php
Normal file
20
config/autoload/entity-manager.global.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
'orm' => [
|
||||
'proxies_dir' => 'data/proxies',
|
||||
],
|
||||
'connection' => [
|
||||
'driver' => 'pdo_mysql',
|
||||
'user' => env('DB_USER'),
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'dbname' => env('DB_NAME', 'shlink'),
|
||||
'charset' => 'utf8',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
|
@ -1,7 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'debug' => true,
|
||||
|
||||
'debug' => true,
|
||||
'config_cache_enabled' => false,
|
||||
|
||||
];
|
||||
|
|
|
@ -11,6 +11,10 @@ return [
|
|||
Command\GetVisitsCommand::class,
|
||||
Command\ProcessVisitsCommand::class,
|
||||
Command\Config\GenerateCharsetCommand::class,
|
||||
Command\Config\GenerateSecretCommand::class,
|
||||
Command\Api\GenerateKeyCommand::class,
|
||||
Command\Api\DisableKeyCommand::class,
|
||||
Command\Api\ListKeysCommand::class,
|
||||
]
|
||||
],
|
||||
|
||||
|
|
|
@ -17,6 +17,10 @@ return [
|
|||
Command\ProcessVisitsCommand::class => AnnotatedFactory::class,
|
||||
Command\ProcessVisitsCommand::class => AnnotatedFactory::class,
|
||||
Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class,
|
||||
Command\Config\GenerateSecretCommand::class => AnnotatedFactory::class,
|
||||
Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class,
|
||||
Command\Api\DisableKeyCommand::class => AnnotatedFactory::class,
|
||||
Command\Api\ListKeysCommand::class => AnnotatedFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
|
Binary file not shown.
|
@ -1,8 +1,8 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Shlink 1.0\n"
|
||||
"POT-Creation-Date: 2016-08-01 21:21+0200\n"
|
||||
"PO-Revision-Date: 2016-08-01 21:22+0200\n"
|
||||
"POT-Creation-Date: 2016-08-07 20:16+0200\n"
|
||||
"PO-Revision-Date: 2016-08-07 20:18+0200\n"
|
||||
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: es_ES\n"
|
||||
|
@ -17,6 +17,46 @@ msgstr ""
|
|||
"X-Poedit-SearchPath-0: src\n"
|
||||
"X-Poedit-SearchPath-1: config\n"
|
||||
|
||||
msgid "Disables an API key."
|
||||
msgstr "Desahbilita una clave de API."
|
||||
|
||||
msgid "The API key to disable"
|
||||
msgstr "La clave de API a deshabilitar"
|
||||
|
||||
#, php-format
|
||||
msgid "API key %s properly disabled"
|
||||
msgstr "Clave de API %s deshabilitada correctamente"
|
||||
|
||||
#, php-format
|
||||
msgid "API key \"%s\" does not exist."
|
||||
msgstr "La clave de API \"%s\" no existe."
|
||||
|
||||
msgid "Generates a new valid API key."
|
||||
msgstr "Genera una nueva clave de API válida."
|
||||
|
||||
msgid "The date in which the API key should expire. Use any valid PHP format."
|
||||
msgstr ""
|
||||
"La fecha en la que la clave de API debe expirar. Utiliza cualquier valor "
|
||||
"válido en PHP."
|
||||
|
||||
msgid "Generated API key"
|
||||
msgstr "Generada clave de API"
|
||||
|
||||
msgid "Lists all the available API keys."
|
||||
msgstr "Lista todas las claves de API disponibles."
|
||||
|
||||
msgid "Tells if only enabled API keys should be returned."
|
||||
msgstr "Define si sólo las claves de API habilitadas deben ser devueltas."
|
||||
|
||||
msgid "Key"
|
||||
msgstr "Clave"
|
||||
|
||||
msgid "Expiration date"
|
||||
msgstr "Fecha de caducidad"
|
||||
|
||||
msgid "Is enabled"
|
||||
msgstr "Está habilitada"
|
||||
|
||||
#, php-format
|
||||
msgid ""
|
||||
"Generates a character set sample just by shuffling the default one, \"%s\". "
|
||||
|
|
62
module/CLI/src/Command/Api/DisableKeyCommand.php
Normal file
62
module/CLI/src/Command/Api/DisableKeyCommand.php
Normal file
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class DisableKeyCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var ApiKeyServiceInterface
|
||||
*/
|
||||
private $apiKeyService;
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
|
||||
/**
|
||||
* DisableKeyCommand constructor.
|
||||
* @param ApiKeyServiceInterface|ApiKeyService $apiKeyService
|
||||
* @param TranslatorInterface $translator
|
||||
*
|
||||
* @Inject({ApiKeyService::class, "translator"})
|
||||
*/
|
||||
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
|
||||
{
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
$this->translator = $translator;
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
public function configure()
|
||||
{
|
||||
$this->setName('api-key:disable')
|
||||
->setDescription($this->translator->translate('Disables an API key.'))
|
||||
->addArgument('apiKey', InputArgument::REQUIRED, $this->translator->translate('The API key to disable'));
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$apiKey = $input->getArgument('apiKey');
|
||||
|
||||
try {
|
||||
$this->apiKeyService->disable($apiKey);
|
||||
$output->writeln(sprintf(
|
||||
$this->translator->translate('API key %s properly disabled'),
|
||||
'<info>' . $apiKey . '</info>'
|
||||
));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$output->writeln(sprintf(
|
||||
'<error>' . $this->translator->translate('API key "%s" does not exist.') . '</error>',
|
||||
$apiKey
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
56
module/CLI/src/Command/Api/GenerateKeyCommand.php
Normal file
56
module/CLI/src/Command/Api/GenerateKeyCommand.php
Normal file
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class GenerateKeyCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var ApiKeyServiceInterface
|
||||
*/
|
||||
private $apiKeyService;
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
|
||||
/**
|
||||
* GenerateKeyCommand constructor.
|
||||
* @param ApiKeyServiceInterface|ApiKeyService $apiKeyService
|
||||
* @param TranslatorInterface $translator
|
||||
*
|
||||
* @Inject({ApiKeyService::class, "translator"})
|
||||
*/
|
||||
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
|
||||
{
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
$this->translator = $translator;
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
public function configure()
|
||||
{
|
||||
$this->setName('api-key:generate')
|
||||
->setDescription($this->translator->translate('Generates a new valid API key.'))
|
||||
->addOption(
|
||||
'expirationDate',
|
||||
'e',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
$this->translator->translate('The date in which the API key should expire. Use any valid PHP format.')
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$expirationDate = $input->getOption('expirationDate');
|
||||
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? new \DateTime($expirationDate) : null);
|
||||
$output->writeln($this->translator->translate('Generated API key') . sprintf(': <info>%s</info>', $apiKey));
|
||||
}
|
||||
}
|
108
module/CLI/src/Command/Api/ListKeysCommand.php
Normal file
108
module/CLI/src/Command/Api/ListKeysCommand.php
Normal file
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class ListKeysCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var ApiKeyServiceInterface
|
||||
*/
|
||||
private $apiKeyService;
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
|
||||
/**
|
||||
* ListKeysCommand constructor.
|
||||
* @param ApiKeyServiceInterface|ApiKeyService $apiKeyService
|
||||
* @param TranslatorInterface $translator
|
||||
*
|
||||
* @Inject({ApiKeyService::class, "translator"})
|
||||
*/
|
||||
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
|
||||
{
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
$this->translator = $translator;
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
public function configure()
|
||||
{
|
||||
$this->setName('api-key:list')
|
||||
->setDescription($this->translator->translate('Lists all the available API keys.'))
|
||||
->addOption(
|
||||
'enabledOnly',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
$this->translator->translate('Tells if only enabled API keys should be returned.')
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$enabledOnly = $input->getOption('enabledOnly');
|
||||
$list = $this->apiKeyService->listKeys($enabledOnly);
|
||||
|
||||
$table = new Table($output);
|
||||
if ($enabledOnly) {
|
||||
$table->setHeaders([
|
||||
$this->translator->translate('Key'),
|
||||
$this->translator->translate('Expiration date'),
|
||||
]);
|
||||
} else {
|
||||
$table->setHeaders([
|
||||
$this->translator->translate('Key'),
|
||||
$this->translator->translate('Is enabled'),
|
||||
$this->translator->translate('Expiration date'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var ApiKey $row */
|
||||
foreach ($list as $row) {
|
||||
$key = $row->getKey();
|
||||
$expiration = $row->getExpirationDate();
|
||||
$rowData = [];
|
||||
|
||||
if ($enabledOnly) {
|
||||
$rowData[] = $key;
|
||||
} else {
|
||||
$rowData[] = $row->isEnabled() ? $this->getSuccessString($key) : $this->getErrorString($key);
|
||||
$rowData[] = $row->isEnabled() ? $this->getSuccessString('+++') : $this->getErrorString('---');
|
||||
}
|
||||
|
||||
$rowData[] = isset($expiration) ? $expiration->format(\DateTime::ISO8601) : '-';
|
||||
$table->addRow($rowData);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $string
|
||||
* @return string
|
||||
*/
|
||||
protected function getErrorString($string)
|
||||
{
|
||||
return sprintf('<fg=red>%s</>', $string);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $string
|
||||
* @return string
|
||||
*/
|
||||
protected function getSuccessString($string)
|
||||
{
|
||||
return sprintf('<info>%s</info>', $string);
|
||||
}
|
||||
}
|
45
module/CLI/src/Command/Config/GenerateSecretCommand.php
Normal file
45
module/CLI/src/Command/Config/GenerateSecretCommand.php
Normal file
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\CLI\Command\Config;
|
||||
|
||||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class GenerateSecretCommand extends Command
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
|
||||
/**
|
||||
* GenerateCharsetCommand constructor.
|
||||
* @param TranslatorInterface $translator
|
||||
*
|
||||
* @Inject({"translator"})
|
||||
*/
|
||||
public function __construct(TranslatorInterface $translator)
|
||||
{
|
||||
$this->translator = $translator;
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
public function configure()
|
||||
{
|
||||
$this->setName('config:generate-secret')
|
||||
->setDescription($this->translator->translate(
|
||||
'Generates a random secret string that can be used for JWT token encryption'
|
||||
));
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$secret = $this->generateRandomString(32);
|
||||
$output->writeln($this->translator->translate('Secret key:') . sprintf(' <info>%s</info>', $secret));
|
||||
}
|
||||
}
|
62
module/CLI/test/Command/Api/DisableKeyCommandTest.php
Normal file
62
module/CLI/test/Command/Api/DisableKeyCommandTest.php
Normal file
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit_Framework_TestCase as TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Zend\I18n\Translator\Translator;
|
||||
|
||||
class DisableKeyCommandTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var CommandTester
|
||||
*/
|
||||
protected $commandTester;
|
||||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
protected $apiKeyService;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
|
||||
$command = new DisableKeyCommand($this->apiKeyService->reveal(), Translator::factory([]));
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function providedApiKeyIsDisabled()
|
||||
{
|
||||
$apiKey = 'abcd1234';
|
||||
$this->apiKeyService->disable($apiKey)->shouldBeCalledTimes(1);
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:disable',
|
||||
'apiKey' => $apiKey,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function errorIsReturnedIfServiceThrowsException()
|
||||
{
|
||||
$apiKey = 'abcd1234';
|
||||
$this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class)
|
||||
->shouldBeCalledTimes(1);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:disable',
|
||||
'apiKey' => $apiKey,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertEquals('API key "abcd1234" does not exist.' . PHP_EOL, $output);
|
||||
}
|
||||
}
|
55
module/CLI/test/Command/Api/GenerateKeyCommandTest.php
Normal file
55
module/CLI/test/Command/Api/GenerateKeyCommandTest.php
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit_Framework_TestCase as TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Zend\I18n\Translator\Translator;
|
||||
|
||||
class GenerateKeyCommandTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var CommandTester
|
||||
*/
|
||||
protected $commandTester;
|
||||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
protected $apiKeyService;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
|
||||
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), Translator::factory([]));
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function noExpirationDateIsDefinedIfNotProvided()
|
||||
{
|
||||
$this->apiKeyService->create(null)->shouldBeCalledTimes(1);
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:generate',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function expirationDateIsDefinedIfWhenProvided()
|
||||
{
|
||||
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1);
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:generate',
|
||||
'--expirationDate' => '2016-01-01',
|
||||
]);
|
||||
}
|
||||
}
|
62
module/CLI/test/Command/Api/ListKeysCommandTest.php
Normal file
62
module/CLI/test/Command/Api/ListKeysCommandTest.php
Normal file
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit_Framework_TestCase as TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Zend\I18n\Translator\Translator;
|
||||
|
||||
class ListKeysCommandTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var CommandTester
|
||||
*/
|
||||
protected $commandTester;
|
||||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
protected $apiKeyService;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
|
||||
$command = new ListKeysCommand($this->apiKeyService->reveal(), Translator::factory([]));
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function ifEnabledOnlyIsNotProvidedEverythingIsListed()
|
||||
{
|
||||
$this->apiKeyService->listKeys(false)->willReturn([
|
||||
new ApiKey(),
|
||||
new ApiKey(),
|
||||
new ApiKey(),
|
||||
])->shouldBeCalledTimes(1);
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:list',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function ifEnabledOnlyIsProvidedOnlyThoseKeysAreListed()
|
||||
{
|
||||
$this->apiKeyService->listKeys(true)->willReturn([
|
||||
new ApiKey(),
|
||||
new ApiKey(),
|
||||
])->shouldBeCalledTimes(1);
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:list',
|
||||
'--enabledOnly' => true,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -30,12 +30,14 @@ class EntityManagerFactory implements FactoryInterface
|
|||
$globalConfig = $container->get('config');
|
||||
$isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false;
|
||||
$cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache();
|
||||
$dbConfig = isset($globalConfig['database']) ? $globalConfig['database'] : [];
|
||||
$emConfig = isset($globalConfig['entity_manager']) ? $globalConfig['entity_manager'] : [];
|
||||
$connecitonConfig = isset($emConfig['connection']) ? $emConfig['connection'] : [];
|
||||
$ormConfig = isset($emConfig['orm']) ? $emConfig['orm'] : [];
|
||||
|
||||
return EntityManager::create($dbConfig, Setup::createAnnotationMetadataConfiguration(
|
||||
['module/Core/src/Entity'],
|
||||
return EntityManager::create($connecitonConfig, Setup::createAnnotationMetadataConfiguration(
|
||||
isset($ormConfig['entities_paths']) ? $ormConfig['entities_paths'] : [],
|
||||
$isDevMode,
|
||||
'data/proxies',
|
||||
isset($ormConfig['proxies_dir']) ? $ormConfig['proxies_dir'] : null,
|
||||
$cache,
|
||||
false
|
||||
));
|
||||
|
|
|
@ -26,8 +26,10 @@ class EntityManagerFactoryTest extends TestCase
|
|||
$sm = new ServiceManager(['services' => [
|
||||
'config' => [
|
||||
'debug' => true,
|
||||
'database' => [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'entity_manager' => [
|
||||
'connection' => [
|
||||
'driver' => 'pdo_sqlite',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
|
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,
|
||||
|
|
12
module/Core/config/entity-manager.config.php
Normal file
12
module/Core/config/entity-manager.config.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
'orm' => [
|
||||
'entities_paths' => [
|
||||
__DIR__ . '/../src/Entity',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
|
@ -1,103 +0,0 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||
|
||||
/**
|
||||
* Class RestToken
|
||||
* @author
|
||||
* @link
|
||||
*
|
||||
* @ORM\Entity()
|
||||
* @ORM\Table(name="rest_tokens")
|
||||
*/
|
||||
class RestToken extends AbstractEntity
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
/**
|
||||
* The default interval is 20 minutes
|
||||
*/
|
||||
const DEFAULT_INTERVAL = 'PT20M';
|
||||
|
||||
/**
|
||||
* @var \DateTime
|
||||
* @ORM\Column(type="datetime", name="expiration_date", nullable=false)
|
||||
*/
|
||||
protected $expirationDate;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(nullable=false)
|
||||
*/
|
||||
protected $token;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->updateExpiration();
|
||||
$this->setRandomTokenKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getExpirationDate()
|
||||
{
|
||||
return $this->expirationDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $expirationDate
|
||||
* @return $this
|
||||
*/
|
||||
public function setExpirationDate($expirationDate)
|
||||
{
|
||||
$this->expirationDate = $expirationDate;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getToken()
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @return $this
|
||||
*/
|
||||
public function setToken($token)
|
||||
{
|
||||
$this->token = $token;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isExpired()
|
||||
{
|
||||
return new \DateTime() > $this->expirationDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the expiration of the token, setting it to the default interval in the future
|
||||
* @return $this
|
||||
*/
|
||||
public function updateExpiration()
|
||||
{
|
||||
return $this->setExpirationDate((new \DateTime())->add(new \DateInterval(self::DEFAULT_INTERVAL)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a random unique token key for this RestToken
|
||||
* @return RestToken
|
||||
*/
|
||||
public function setRandomTokenKey()
|
||||
{
|
||||
return $this->setToken($this->generateV4Uuid());
|
||||
}
|
||||
}
|
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);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
|
||||
use Shlinkio\Shlink\Rest\Action;
|
||||
use Shlinkio\Shlink\Rest\Authentication\JWTService;
|
||||
use Shlinkio\Shlink\Rest\Middleware;
|
||||
use Shlinkio\Shlink\Rest\Service;
|
||||
use Zend\ServiceManager\Factory\InvokableFactory;
|
||||
|
@ -9,7 +10,8 @@ return [
|
|||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
Service\RestTokenService::class => AnnotatedFactory::class,
|
||||
JWTService::class => AnnotatedFactory::class,
|
||||
Service\ApiKeyService::class => AnnotatedFactory::class,
|
||||
|
||||
Action\AuthenticateAction::class => AnnotatedFactory::class,
|
||||
Action\CreateShortcodeAction::class => AnnotatedFactory::class,
|
||||
|
|
12
module/Rest/config/entity-manager.config.php
Normal file
12
module/Rest/config/entity-manager.config.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
'orm' => [
|
||||
'entities_paths' => [
|
||||
__DIR__ . '/../src/Entity',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
Binary file not shown.
|
@ -1,8 +1,8 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Shlink 1.0\n"
|
||||
"POT-Creation-Date: 2016-07-27 08:53+0200\n"
|
||||
"PO-Revision-Date: 2016-07-27 08:53+0200\n"
|
||||
"POT-Creation-Date: 2016-08-07 20:19+0200\n"
|
||||
"PO-Revision-Date: 2016-08-07 20:21+0200\n"
|
||||
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: es_ES\n"
|
||||
|
@ -17,11 +17,13 @@ msgstr ""
|
|||
"X-Poedit-SearchPath-0: config\n"
|
||||
"X-Poedit-SearchPath-1: src\n"
|
||||
|
||||
msgid "You have to provide both \"username\" and \"password\""
|
||||
msgstr "Debes proporcionar tanto \"username\" como \"password\""
|
||||
msgid "You have to provide a valid API key under the \"apiKey\" param name."
|
||||
msgstr ""
|
||||
"Debes proporcionar una clave de API válida bajo el nombre de parámetro "
|
||||
"\"apiKey\"."
|
||||
|
||||
msgid "Invalid username and/or password"
|
||||
msgstr "Usuario y/o contraseña no válidos"
|
||||
msgid "Provided API key does not exist or is invalid."
|
||||
msgstr "La clave de API proporcionada no existe o es inválida."
|
||||
|
||||
msgid "A URL was not provided"
|
||||
msgstr "No se ha proporcionado una URL"
|
||||
|
@ -47,6 +49,16 @@ msgstr "No se ha encontrado una URL para el código corto \"%s\""
|
|||
msgid "Provided short code \"%s\" has an invalid format"
|
||||
msgstr "El código corto proporcionado \"%s\" tiene un formato no inválido"
|
||||
|
||||
#, php-format
|
||||
msgid "You need to provide the Bearer type in the %s header."
|
||||
msgstr "Debes proporcionar el typo Bearer en la cabecera %s."
|
||||
|
||||
#, php-format
|
||||
msgid "Provided authorization type %s is not supported. Use Bearer instead."
|
||||
msgstr ""
|
||||
"El tipo de autorización proporcionado %s no está soportado. En vez de eso "
|
||||
"utiliza Bearer."
|
||||
|
||||
#, php-format
|
||||
msgid ""
|
||||
"Missing or invalid auth token provided. Perform a new authentication request "
|
||||
|
@ -56,8 +68,14 @@ msgstr ""
|
|||
"una nueva petición de autenticación y envía el token proporcionado en cada "
|
||||
"nueva petición en la cabecera \"%s\""
|
||||
|
||||
msgid "Requested route does not exist."
|
||||
msgstr "La ruta solicitada no existe."
|
||||
#~ msgid "You have to provide both \"username\" and \"password\""
|
||||
#~ msgstr "Debes proporcionar tanto \"username\" como \"password\""
|
||||
|
||||
#~ msgid "Invalid username and/or password"
|
||||
#~ msgstr "Usuario y/o contraseña no válidos"
|
||||
|
||||
#~ msgid "Requested route does not exist."
|
||||
#~ msgstr "La ruta solicitada no existe."
|
||||
|
||||
#~ msgid "RestToken not found for token \"%s\""
|
||||
#~ msgstr "No se ha encontrado un RestToken para el token \"%s\""
|
||||
|
|
|
@ -2,37 +2,48 @@
|
|||
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\Exception\AuthenticationException;
|
||||
use Shlinkio\Shlink\Rest\Service\RestTokenService;
|
||||
use Shlinkio\Shlink\Rest\Service\RestTokenServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Authentication\JWTService;
|
||||
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Util\RestUtils;
|
||||
use Zend\Diactoros\Response\JsonResponse;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class AuthenticateAction extends AbstractRestAction
|
||||
{
|
||||
/**
|
||||
* @var RestTokenServiceInterface
|
||||
*/
|
||||
private $restTokenService;
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
/**
|
||||
* @var ApiKeyService|ApiKeyServiceInterface
|
||||
*/
|
||||
private $apiKeyService;
|
||||
/**
|
||||
* @var JWTServiceInterface
|
||||
*/
|
||||
private $jwtService;
|
||||
|
||||
/**
|
||||
* AuthenticateAction constructor.
|
||||
* @param RestTokenServiceInterface|RestTokenService $restTokenService
|
||||
* @param ApiKeyServiceInterface|ApiKeyService $apiKeyService
|
||||
* @param JWTServiceInterface|JWTService $jwtService
|
||||
* @param TranslatorInterface $translator
|
||||
*
|
||||
* @Inject({RestTokenService::class, "translator"})
|
||||
* @Inject({ApiKeyService::class, JWTService::class, "translator"})
|
||||
*/
|
||||
public function __construct(RestTokenServiceInterface $restTokenService, TranslatorInterface $translator)
|
||||
{
|
||||
$this->restTokenService = $restTokenService;
|
||||
public function __construct(
|
||||
ApiKeyServiceInterface $apiKeyService,
|
||||
JWTServiceInterface $jwtService,
|
||||
TranslatorInterface $translator
|
||||
) {
|
||||
$this->translator = $translator;
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
$this->jwtService = $jwtService;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -44,21 +55,26 @@ class AuthenticateAction extends AbstractRestAction
|
|||
public function dispatch(Request $request, Response $response, callable $out = null)
|
||||
{
|
||||
$authData = $request->getParsedBody();
|
||||
if (! isset($authData['username'], $authData['password'])) {
|
||||
if (! isset($authData['apiKey'])) {
|
||||
return new JsonResponse([
|
||||
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
|
||||
'message' => $this->translator->translate('You have to provide both "username" and "password"'),
|
||||
'message' => $this->translator->translate(
|
||||
'You have to provide a valid API key under the "apiKey" param name.'
|
||||
),
|
||||
], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $this->restTokenService->createToken($authData['username'], $authData['password']);
|
||||
return new JsonResponse(['token' => $token->getToken()]);
|
||||
} catch (AuthenticationException $e) {
|
||||
// Authenticate using provided API key
|
||||
$apiKey = $this->apiKeyService->getByKey($authData['apiKey']);
|
||||
if (! $apiKey->isValid()) {
|
||||
return new JsonResponse([
|
||||
'error' => RestUtils::getRestErrorCodeFromException($e),
|
||||
'message' => $this->translator->translate('Invalid username and/or password'),
|
||||
'error' => RestUtils::INVALID_API_KEY_ERROR,
|
||||
'message' => $this->translator->translate('Provided API key does not exist or is invalid.'),
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Generate a JSON Web Token that will be used for authorization in next requests
|
||||
$token = $this->jwtService->create($apiKey);
|
||||
return new JsonResponse(['token' => $token]);
|
||||
}
|
||||
}
|
||||
|
|
113
module/Rest/src/Authentication/JWTService.php
Normal file
113
module/Rest/src/Authentication/JWTService.php
Normal file
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\Rest\Authentication;
|
||||
|
||||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||
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
|
||||
*
|
||||
* @Inject({AppOptions::class})
|
||||
*/
|
||||
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);
|
||||
}
|
137
module/Rest/src/Entity/ApiKey.php
Normal file
137
module/Rest/src/Entity/ApiKey.php
Normal file
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\Rest\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||
|
||||
/**
|
||||
* Class ApiKey
|
||||
* @author Shlink
|
||||
* @link http://shlink.io
|
||||
*
|
||||
* @ORM\Entity()
|
||||
* @ORM\Table(name="api_keys")
|
||||
*/
|
||||
class ApiKey extends AbstractEntity
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(name="`key`", nullable=false, unique=true)
|
||||
*/
|
||||
protected $key;
|
||||
/**
|
||||
* @var \DateTime
|
||||
* @ORM\Column(name="expiration_date", nullable=true, type="datetime")
|
||||
*/
|
||||
protected $expirationDate;
|
||||
/**
|
||||
* @var bool
|
||||
* @ORM\Column(type="boolean")
|
||||
*/
|
||||
protected $enabled;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->enabled = true;
|
||||
$this->key = $this->generateV4Uuid();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getKey()
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @return $this
|
||||
*/
|
||||
public function setKey($key)
|
||||
{
|
||||
$this->key = $key;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getExpirationDate()
|
||||
{
|
||||
return $this->expirationDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $expirationDate
|
||||
* @return $this
|
||||
*/
|
||||
public function setExpirationDate($expirationDate)
|
||||
{
|
||||
$this->expirationDate = $expirationDate;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isExpired()
|
||||
{
|
||||
if (! isset($this->expirationDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expirationDate < new \DateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean
|
||||
*/
|
||||
public function isEnabled()
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param boolean $enabled
|
||||
* @return $this
|
||||
*/
|
||||
public function setEnabled($enabled)
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables this API key
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function disable()
|
||||
{
|
||||
return $this->setEnabled(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if this api key is enabled and not expired
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isValid()
|
||||
{
|
||||
return $this->isEnabled() && ! $this->isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* The string repesentation of an API key is the key itself
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,9 +4,9 @@ namespace Shlinkio\Shlink\Rest\Middleware;
|
|||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Rest\Service\RestTokenService;
|
||||
use Shlinkio\Shlink\Rest\Service\RestTokenServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Authentication\JWTService;
|
||||
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
|
||||
use Shlinkio\Shlink\Rest\Util\RestUtils;
|
||||
use Zend\Diactoros\Response\JsonResponse;
|
||||
use Zend\Expressive\Router\RouteResult;
|
||||
|
@ -15,28 +15,28 @@ use Zend\Stratigility\MiddlewareInterface;
|
|||
|
||||
class CheckAuthenticationMiddleware implements MiddlewareInterface
|
||||
{
|
||||
const AUTH_TOKEN_HEADER = 'X-Auth-Token';
|
||||
const AUTHORIZATION_HEADER = 'Authorization';
|
||||
|
||||
/**
|
||||
* @var RestTokenServiceInterface
|
||||
*/
|
||||
private $restTokenService;
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
/**
|
||||
* @var JWTServiceInterface
|
||||
*/
|
||||
private $jwtService;
|
||||
|
||||
/**
|
||||
* CheckAuthenticationMiddleware constructor.
|
||||
* @param RestTokenServiceInterface|RestTokenService $restTokenService
|
||||
* @param JWTServiceInterface|JWTService $jwtService
|
||||
* @param TranslatorInterface $translator
|
||||
*
|
||||
* @Inject({RestTokenService::class, "translator"})
|
||||
* @Inject({JWTService::class, "translator"})
|
||||
*/
|
||||
public function __construct(RestTokenServiceInterface $restTokenService, TranslatorInterface $translator)
|
||||
public function __construct(JWTServiceInterface $jwtService, TranslatorInterface $translator)
|
||||
{
|
||||
$this->restTokenService = $restTokenService;
|
||||
$this->translator = $translator;
|
||||
$this->jwtService = $jwtService;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -78,21 +78,46 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface
|
|||
}
|
||||
|
||||
// Check that the auth header was provided, and that it belongs to a non-expired token
|
||||
if (! $request->hasHeader(self::AUTH_TOKEN_HEADER)) {
|
||||
if (! $request->hasHeader(self::AUTHORIZATION_HEADER)) {
|
||||
return $this->createTokenErrorResponse();
|
||||
}
|
||||
|
||||
$authToken = $request->getHeaderLine(self::AUTH_TOKEN_HEADER);
|
||||
// Get token making sure the an authorization type is provided
|
||||
$authToken = $request->getHeaderLine(self::AUTHORIZATION_HEADER);
|
||||
$authTokenParts = explode(' ', $authToken);
|
||||
if (count($authTokenParts) === 1) {
|
||||
return new JsonResponse([
|
||||
'error' => RestUtils::INVALID_AUTHORIZATION_ERROR,
|
||||
'message' => sprintf($this->translator->translate(
|
||||
'You need to provide the Bearer type in the %s header.'
|
||||
), self::AUTHORIZATION_HEADER),
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Make sure the authorization type is Bearer
|
||||
list($authType, $jwt) = $authTokenParts;
|
||||
if (strtolower($authType) !== 'bearer') {
|
||||
return new JsonResponse([
|
||||
'error' => RestUtils::INVALID_AUTHORIZATION_ERROR,
|
||||
'message' => sprintf($this->translator->translate(
|
||||
'Provided authorization type %s is not supported. Use Bearer instead.'
|
||||
), $authType),
|
||||
], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$restToken = $this->restTokenService->getByToken($authToken);
|
||||
if ($restToken->isExpired()) {
|
||||
if (! $this->jwtService->verify($jwt)) {
|
||||
return $this->createTokenErrorResponse();
|
||||
}
|
||||
|
||||
// Update the token expiration and continue to next middleware
|
||||
$this->restTokenService->updateExpiration($restToken);
|
||||
return $out($request, $response);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$jwt = $this->jwtService->refresh($jwt);
|
||||
/** @var Response $response */
|
||||
$response = $out($request, $response);
|
||||
|
||||
// Return the response with the updated token on it
|
||||
return $response->withHeader(self::AUTHORIZATION_HEADER, 'Bearer ' . $jwt);
|
||||
} catch (AuthenticationException $e) {
|
||||
return $this->createTokenErrorResponse();
|
||||
}
|
||||
}
|
||||
|
@ -106,7 +131,7 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface
|
|||
'Missing or invalid auth token provided. Perform a new authentication request and send provided '
|
||||
. 'token on every new request on the "%s" header'
|
||||
),
|
||||
self::AUTH_TOKEN_HEADER
|
||||
self::AUTHORIZATION_HEADER
|
||||
),
|
||||
], 401);
|
||||
}
|
||||
|
|
106
module/Rest/src/Service/ApiKeyService.php
Normal file
106
module/Rest/src/Service/ApiKeyService.php
Normal file
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\Rest\Service;
|
||||
|
||||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class ApiKeyService implements ApiKeyServiceInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $em;
|
||||
|
||||
/**
|
||||
* ApiKeyService constructor.
|
||||
* @param EntityManagerInterface $em
|
||||
*
|
||||
* @Inject({"em"})
|
||||
*/
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ApiKey with provided expiration date
|
||||
*
|
||||
* @param \DateTime $expirationDate
|
||||
* @return ApiKey
|
||||
*/
|
||||
public function create(\DateTime $expirationDate = null)
|
||||
{
|
||||
$key = new ApiKey();
|
||||
if (isset($expirationDate)) {
|
||||
$key->setExpirationDate($expirationDate);
|
||||
}
|
||||
|
||||
$this->em->persist($key);
|
||||
$this->em->flush();
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if provided key is a valid api key
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public function check($key)
|
||||
{
|
||||
/** @var ApiKey $apiKey */
|
||||
$apiKey = $this->getByKey($key);
|
||||
if (! isset($apiKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $apiKey->isValid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables provided api key
|
||||
*
|
||||
* @param string $key
|
||||
* @return ApiKey
|
||||
*/
|
||||
public function disable($key)
|
||||
{
|
||||
/** @var ApiKey $apiKey */
|
||||
$apiKey = $this->getByKey($key);
|
||||
if (! isset($apiKey)) {
|
||||
throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key));
|
||||
}
|
||||
|
||||
$apiKey->disable();
|
||||
$this->em->flush();
|
||||
return $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all existing appi keys
|
||||
*
|
||||
* @param bool $enabledOnly Tells if only enabled keys should be returned
|
||||
* @return ApiKey[]
|
||||
*/
|
||||
public function listKeys($enabledOnly = false)
|
||||
{
|
||||
$conditions = $enabledOnly ? ['enabled' => true] : [];
|
||||
return $this->em->getRepository(ApiKey::class)->findBy($conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find one API key by its key string
|
||||
*
|
||||
* @param string $key
|
||||
* @return ApiKey|null
|
||||
*/
|
||||
public function getByKey($key)
|
||||
{
|
||||
return $this->em->getRepository(ApiKey::class)->findOneBy([
|
||||
'key' => $key,
|
||||
]);
|
||||
}
|
||||
}
|
47
module/Rest/src/Service/ApiKeyServiceInterface.php
Normal file
47
module/Rest/src/Service/ApiKeyServiceInterface.php
Normal file
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\Rest\Service;
|
||||
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface ApiKeyServiceInterface
|
||||
{
|
||||
/**
|
||||
* Creates a new ApiKey with provided expiration date
|
||||
*
|
||||
* @param \DateTime $expirationDate
|
||||
* @return ApiKey
|
||||
*/
|
||||
public function create(\DateTime $expirationDate = null);
|
||||
|
||||
/**
|
||||
* Checks if provided key is a valid api key
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public function check($key);
|
||||
|
||||
/**
|
||||
* Disables provided api key
|
||||
*
|
||||
* @param string $key
|
||||
* @return ApiKey
|
||||
*/
|
||||
public function disable($key);
|
||||
|
||||
/**
|
||||
* Lists all existing appi keys
|
||||
*
|
||||
* @param bool $enabledOnly Tells if only enabled keys should be returned
|
||||
* @return ApiKey[]
|
||||
*/
|
||||
public function listKeys($enabledOnly = false);
|
||||
|
||||
/**
|
||||
* Tries to find one API key by its key string
|
||||
*
|
||||
* @param string $key
|
||||
* @return ApiKey|null
|
||||
*/
|
||||
public function getByKey($key);
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\Rest\Service;
|
||||
|
||||
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Core\Entity\RestToken;
|
||||
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
|
||||
|
||||
class RestTokenService implements RestTokenServiceInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $em;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $restConfig;
|
||||
|
||||
/**
|
||||
* ShortUrlService constructor.
|
||||
* @param EntityManagerInterface $em
|
||||
* @param array $restConfig
|
||||
*
|
||||
* @Inject({"em", "config.rest"})
|
||||
*/
|
||||
public function __construct(EntityManagerInterface $em, array $restConfig)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->restConfig = $restConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token
|
||||
* @return RestToken
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getByToken($token)
|
||||
{
|
||||
$restToken = $this->em->getRepository(RestToken::class)->findOneBy([
|
||||
'token' => $token,
|
||||
]);
|
||||
if (! isset($restToken)) {
|
||||
throw new InvalidArgumentException(sprintf('RestToken not found for token "%s"', $token));
|
||||
}
|
||||
|
||||
return $restToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a new RestToken if username and password are correct
|
||||
* @param $username
|
||||
* @param $password
|
||||
* @return RestToken
|
||||
* @throws AuthenticationException
|
||||
*/
|
||||
public function createToken($username, $password)
|
||||
{
|
||||
$this->processCredentials($username, $password);
|
||||
|
||||
$restToken = new RestToken();
|
||||
$this->em->persist($restToken);
|
||||
$this->em->flush();
|
||||
|
||||
return $restToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
*/
|
||||
protected function processCredentials($username, $password)
|
||||
{
|
||||
$configUsername = strtolower(trim($this->restConfig['username']));
|
||||
$providedUsername = strtolower(trim($username));
|
||||
$configPassword = trim($this->restConfig['password']);
|
||||
$providedPassword = trim($password);
|
||||
|
||||
if ($configUsername === $providedUsername && $configPassword === $providedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If credentials are not correct, throw exception
|
||||
throw AuthenticationException::fromCredentials($providedUsername, $providedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the expiration of provided token, extending its life
|
||||
*
|
||||
* @param RestToken $token
|
||||
*/
|
||||
public function updateExpiration(RestToken $token)
|
||||
{
|
||||
$token->updateExpiration();
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
namespace Shlinkio\Shlink\Rest\Service;
|
||||
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Core\Entity\RestToken;
|
||||
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
|
||||
|
||||
interface RestTokenServiceInterface
|
||||
{
|
||||
/**
|
||||
* @param string $token
|
||||
* @return RestToken
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getByToken($token);
|
||||
|
||||
/**
|
||||
* Creates and returns a new RestToken if username and password are correct
|
||||
* @param $username
|
||||
* @param $password
|
||||
* @return RestToken
|
||||
* @throws AuthenticationException
|
||||
*/
|
||||
public function createToken($username, $password);
|
||||
|
||||
/**
|
||||
* Updates the expiration of provided token, extending its life
|
||||
*
|
||||
* @param RestToken $token
|
||||
*/
|
||||
public function updateExpiration(RestToken $token);
|
||||
}
|
|
@ -12,6 +12,8 @@ class RestUtils
|
|||
const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT';
|
||||
const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS';
|
||||
const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN';
|
||||
const INVALID_AUTHORIZATION_ERROR = 'INVALID_AUTHORIZATION';
|
||||
const INVALID_API_KEY_ERROR = 'INVALID_API_KEY';
|
||||
const NOT_FOUND_ERROR = 'NOT_FOUND';
|
||||
const UNKNOWN_ERROR = 'UNKNOWN_ERROR';
|
||||
|
||||
|
|
|
@ -3,10 +3,10 @@ namespace ShlinkioTest\Shlink\Rest\Action;
|
|||
|
||||
use PHPUnit_Framework_TestCase as TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\RestToken;
|
||||
use Shlinkio\Shlink\Rest\Action\AuthenticateAction;
|
||||
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
|
||||
use Shlinkio\Shlink\Rest\Service\RestTokenService;
|
||||
use Shlinkio\Shlink\Rest\Authentication\JWTService;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Zend\Diactoros\Response;
|
||||
use Zend\Diactoros\ServerRequestFactory;
|
||||
use Zend\I18n\Translator\Translator;
|
||||
|
@ -20,12 +20,21 @@ class AuthenticateActionTest extends TestCase
|
|||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
protected $tokenService;
|
||||
protected $apiKeyService;
|
||||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
protected $jwtService;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->tokenService = $this->prophesize(RestTokenService::class);
|
||||
$this->action = new AuthenticateAction($this->tokenService->reveal(), Translator::factory([]));
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
|
||||
$this->jwtService = $this->prophesize(JWTService::class);
|
||||
$this->action = new AuthenticateAction(
|
||||
$this->apiKeyService->reveal(),
|
||||
$this->jwtService->reveal(),
|
||||
Translator::factory([])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -40,34 +49,31 @@ class AuthenticateActionTest extends TestCase
|
|||
/**
|
||||
* @test
|
||||
*/
|
||||
public function properCredentialsReturnTokenInResponse()
|
||||
public function properApiKeyReturnsTokenInResponse()
|
||||
{
|
||||
$this->tokenService->createToken('foo', 'bar')->willReturn(
|
||||
(new RestToken())->setToken('abc-ABC')
|
||||
)->shouldBeCalledTimes(1);
|
||||
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setId(5))
|
||||
->shouldBeCalledTimes(1);
|
||||
|
||||
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
|
||||
'username' => 'foo',
|
||||
'password' => 'bar',
|
||||
'apiKey' => 'foo',
|
||||
]);
|
||||
$response = $this->action->__invoke($request, new Response());
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response->getBody()->rewind();
|
||||
$this->assertEquals(['token' => 'abc-ABC'], json_decode($response->getBody()->getContents(), true));
|
||||
$this->assertTrue(strpos($response->getBody()->getContents(), '"token"') > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function authenticationExceptionsReturnErrorResponse()
|
||||
public function invalidApiKeyReturnsErrorResponse()
|
||||
{
|
||||
$this->tokenService->createToken('foo', 'bar')->willThrow(new AuthenticationException())
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setEnabled(false))
|
||||
->shouldBeCalledTimes(1);
|
||||
|
||||
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
|
||||
'username' => 'foo',
|
||||
'password' => 'bar',
|
||||
'apiKey' => 'foo',
|
||||
]);
|
||||
$response = $this->action->__invoke($request, new Response());
|
||||
$this->assertEquals(401, $response->getStatusCode());
|
||||
|
|
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');
|
||||
}
|
||||
}
|
|
@ -3,9 +3,8 @@ namespace ShlinkioTest\Shlink\Rest\Middleware;
|
|||
|
||||
use PHPUnit_Framework_TestCase as TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\RestToken;
|
||||
use Shlinkio\Shlink\Rest\Authentication\JWTService;
|
||||
use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware;
|
||||
use Shlinkio\Shlink\Rest\Service\RestTokenService;
|
||||
use Zend\Diactoros\Response;
|
||||
use Zend\Diactoros\ServerRequestFactory;
|
||||
use Zend\Expressive\Router\RouteResult;
|
||||
|
@ -20,18 +19,18 @@ class CheckAuthenticationMiddlewareTest extends TestCase
|
|||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
protected $tokenService;
|
||||
protected $jwtService;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->tokenService = $this->prophesize(RestTokenService::class);
|
||||
$this->middleware = new CheckAuthenticationMiddleware($this->tokenService->reveal(), Translator::factory([]));
|
||||
$this->jwtService = $this->prophesize(JWTService::class);
|
||||
$this->middleware = new CheckAuthenticationMiddleware($this->jwtService->reveal(), Translator::factory([]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function someWhitelistedSituationsFallbackToNextMiddleware()
|
||||
public function someWhiteListedSituationsFallbackToNextMiddleware()
|
||||
{
|
||||
$request = ServerRequestFactory::fromGlobals();
|
||||
$response = new Response();
|
||||
|
@ -92,6 +91,40 @@ class CheckAuthenticationMiddlewareTest extends TestCase
|
|||
$this->assertEquals(401, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function provideAnAuthorizationWithoutTypeReturnsError()
|
||||
{
|
||||
$authToken = 'ABC-abc';
|
||||
$request = ServerRequestFactory::fromGlobals()->withAttribute(
|
||||
RouteResult::class,
|
||||
RouteResult::fromRouteMatch('bar', 'foo', [])
|
||||
)->withHeader(CheckAuthenticationMiddleware::AUTHORIZATION_HEADER, $authToken);
|
||||
|
||||
$response = $this->middleware->__invoke($request, new Response());
|
||||
$this->assertEquals(401, $response->getStatusCode());
|
||||
$this->assertTrue(strpos($response->getBody()->getContents(), 'You need to provide the Bearer type') > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function provideAnAuthorizationWithWrongTypeReturnsError()
|
||||
{
|
||||
$authToken = 'ABC-abc';
|
||||
$request = ServerRequestFactory::fromGlobals()->withAttribute(
|
||||
RouteResult::class,
|
||||
RouteResult::fromRouteMatch('bar', 'foo', [])
|
||||
)->withHeader(CheckAuthenticationMiddleware::AUTHORIZATION_HEADER, 'Basic ' . $authToken);
|
||||
|
||||
$response = $this->middleware->__invoke($request, new Response());
|
||||
$this->assertEquals(401, $response->getStatusCode());
|
||||
$this->assertTrue(
|
||||
strpos($response->getBody()->getContents(), 'Provided authorization type Basic is not supported') > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
|
@ -101,10 +134,8 @@ class CheckAuthenticationMiddlewareTest extends TestCase
|
|||
$request = ServerRequestFactory::fromGlobals()->withAttribute(
|
||||
RouteResult::class,
|
||||
RouteResult::fromRouteMatch('bar', 'foo', [])
|
||||
)->withHeader(CheckAuthenticationMiddleware::AUTH_TOKEN_HEADER, $authToken);
|
||||
$this->tokenService->getByToken($authToken)->willReturn(
|
||||
(new RestToken())->setExpirationDate((new \DateTime())->sub(new \DateInterval('P1D')))
|
||||
)->shouldBeCalledTimes(1);
|
||||
)->withHeader(CheckAuthenticationMiddleware::AUTHORIZATION_HEADER, 'Bearer ' . $authToken);
|
||||
$this->jwtService->verify($authToken)->willReturn(false)->shouldBeCalledTimes(1);
|
||||
|
||||
$response = $this->middleware->__invoke($request, new Response());
|
||||
$this->assertEquals(401, $response->getStatusCode());
|
||||
|
@ -116,18 +147,18 @@ class CheckAuthenticationMiddlewareTest extends TestCase
|
|||
public function provideCorrectTokenUpdatesExpirationAndFallbacksToNextMiddleware()
|
||||
{
|
||||
$authToken = 'ABC-abc';
|
||||
$restToken = (new RestToken())->setExpirationDate((new \DateTime())->add(new \DateInterval('P1D')));
|
||||
$request = ServerRequestFactory::fromGlobals()->withAttribute(
|
||||
RouteResult::class,
|
||||
RouteResult::fromRouteMatch('bar', 'foo', [])
|
||||
)->withHeader(CheckAuthenticationMiddleware::AUTH_TOKEN_HEADER, $authToken);
|
||||
$this->tokenService->getByToken($authToken)->willReturn($restToken)->shouldBeCalledTimes(1);
|
||||
$this->tokenService->updateExpiration($restToken)->shouldBeCalledTimes(1);
|
||||
)->withHeader(CheckAuthenticationMiddleware::AUTHORIZATION_HEADER, 'bearer ' . $authToken);
|
||||
$this->jwtService->verify($authToken)->willReturn(true)->shouldBeCalledTimes(1);
|
||||
$this->jwtService->refresh($authToken)->willReturn($authToken)->shouldBeCalledTimes(1);
|
||||
|
||||
$isCalled = false;
|
||||
$this->assertFalse($isCalled);
|
||||
$this->middleware->__invoke($request, new Response(), function ($req, $resp) use (&$isCalled) {
|
||||
$isCalled = true;
|
||||
return $resp;
|
||||
});
|
||||
$this->assertTrue($isCalled);
|
||||
}
|
||||
|
|
168
module/Rest/test/Service/ApiKeyServiceTest.php
Normal file
168
module/Rest/test/Service/ApiKeyServiceTest.php
Normal file
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
namespace ShlinkioTest\Shlink\Rest\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit_Framework_TestCase as TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
|
||||
class ApiKeyServiceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var ApiKeyService
|
||||
*/
|
||||
protected $service;
|
||||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
protected $em;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->service = new ApiKeyService($this->em->reveal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function keyIsProperlyCreated()
|
||||
{
|
||||
$this->em->flush()->shouldBeCalledTimes(1);
|
||||
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledTimes(1);
|
||||
|
||||
$key = $this->service->create();
|
||||
$this->assertNull($key->getExpirationDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function keyIsProperlyCreatedWithExpirationDate()
|
||||
{
|
||||
$this->em->flush()->shouldBeCalledTimes(1);
|
||||
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledTimes(1);
|
||||
|
||||
$date = new \DateTime('2030-01-01');
|
||||
$key = $this->service->create($date);
|
||||
$this->assertSame($date, $key->getExpirationDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function checkReturnsFalseWhenKeyIsInvalid()
|
||||
{
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['key' => '12345'])->willReturn(null)
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->assertFalse($this->service->check('12345'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function checkReturnsFalseWhenKeyIsDisabled()
|
||||
{
|
||||
$key = new ApiKey();
|
||||
$key->disable();
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['key' => '12345'])->willReturn($key)
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->assertFalse($this->service->check('12345'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function checkReturnsFalseWhenKeyIsExpired()
|
||||
{
|
||||
$key = new ApiKey();
|
||||
$key->setExpirationDate((new \DateTime())->sub(new \DateInterval('P1D')));
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['key' => '12345'])->willReturn($key)
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->assertFalse($this->service->check('12345'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function checkReturnsTrueWhenConditionsAreFavorable()
|
||||
{
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['key' => '12345'])->willReturn(new ApiKey())
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->assertTrue($this->service->check('12345'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function disableThrowsExceptionWhenNoTokenIsFound()
|
||||
{
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['key' => '12345'])->willReturn(null)
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->service->disable('12345');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function disableReturnsDisabledKeyWhenFOund()
|
||||
{
|
||||
$key = new ApiKey();
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['key' => '12345'])->willReturn($key)
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->em->flush()->shouldBeCalledTimes(1);
|
||||
|
||||
$this->assertTrue($key->isEnabled());
|
||||
$returnedKey = $this->service->disable('12345');
|
||||
$this->assertFalse($key->isEnabled());
|
||||
$this->assertSame($key, $returnedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function listFindsAllApiKeys()
|
||||
{
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findBy([])->willReturn([])
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->service->listKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function listEnabledFindsOnlyEnabledApiKeys()
|
||||
{
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findBy(['enabled' => true])->willReturn([])
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->service->listKeys(true);
|
||||
}
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
<?php
|
||||
namespace ShlinkioTest\Shlink\Rest\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit_Framework_TestCase as TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\RestToken;
|
||||
use Shlinkio\Shlink\Rest\Service\RestTokenService;
|
||||
|
||||
class RestTokenServiceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var RestTokenService
|
||||
*/
|
||||
protected $service;
|
||||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
protected $em;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->service = new RestTokenService($this->em->reveal(), [
|
||||
'username' => 'foo',
|
||||
'password' => 'bar',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function tokenIsCreatedIfCredentialsAreCorrect()
|
||||
{
|
||||
$this->em->persist(Argument::type(RestToken::class))->shouldBeCalledTimes(1);
|
||||
$this->em->flush()->shouldBeCalledTimes(1);
|
||||
|
||||
$token = $this->service->createToken('foo', 'bar');
|
||||
$this->assertInstanceOf(RestToken::class, $token);
|
||||
$this->assertFalse($token->isExpired());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @expectedException \Shlinkio\Shlink\Rest\Exception\AuthenticationException
|
||||
*/
|
||||
public function exceptionIsThrownWhileCreatingTokenWithWrongCredentials()
|
||||
{
|
||||
$this->service->createToken('foo', 'wrong');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function restTokenIsReturnedFromTokenString()
|
||||
{
|
||||
$authToken = 'ABC-abc';
|
||||
$theToken = new RestToken();
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['token' => $authToken])->willReturn($theToken)->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(RestToken::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
|
||||
|
||||
$this->assertSame($theToken, $this->service->getByToken($authToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function exceptionIsThrownWhenRequestingWrongToken()
|
||||
{
|
||||
$authToken = 'ABC-abc';
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['token' => $authToken])->willReturn(null)->shouldBeCalledTimes(1);
|
||||
$this->em->getRepository(RestToken::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
|
||||
|
||||
$this->service->getByToken($authToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function updateExpirationFlushesEntityManager()
|
||||
{
|
||||
$token = $this->prophesize(RestToken::class);
|
||||
$token->updateExpiration()->shouldBeCalledTimes(1);
|
||||
$this->em->flush()->shouldBeCalledTimes(1);
|
||||
|
||||
$this->service->updateExpiration($token->reveal());
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue