Merge pull request #1 from acelaya/feature/13

Feature/13
This commit is contained in:
Alejandro Celaya 2016-08-07 21:15:59 +02:00 committed by GitHub
commit 0a4f8c3b0a
44 changed files with 1572 additions and 436 deletions

View file

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

View file

@ -25,7 +25,8 @@
"acelaya/zsm-annotated-services": "^0.2.0",
"doctrine/orm": "^2.5",
"guzzlehttp/guzzle": "^6.2",
"symfony/console": "^3.0"
"symfony/console": "^3.0",
"firebase/php-jwt": "^4.0"
},
"require-dev": {
"phpunit/phpunit": "^5.0",

View file

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

View file

@ -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'
],
],
];

View 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',
],
],
],
];

View file

@ -1,7 +1,8 @@
<?php
return [
'debug' => true,
'debug' => true,
'config_cache_enabled' => false,
];

View file

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

View file

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

View file

@ -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\". "

View 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
));
}
}
}

View 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));
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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',
]);
}
}

View 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,
]);
}
}

View file

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

View file

@ -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',
],
],
],
]]);

View file

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

View file

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

View file

@ -0,0 +1,12 @@
<?php
return [
'entity_manager' => [
'orm' => [
'entities_paths' => [
__DIR__ . '/../src/Entity',
],
],
],
];

View file

@ -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());
}
}

View file

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

View file

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

View file

@ -0,0 +1,12 @@
<?php
return [
'entity_manager' => [
'orm' => [
'entities_paths' => [
__DIR__ . '/../src/Entity',
],
],
],
];

Binary file not shown.

View file

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

View file

@ -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]);
}
}

View 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]);
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Shlinkio\Shlink\Rest\Authentication;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
interface JWTServiceInterface
{
const DEFAULT_LIFETIME = 604800; // 1 week
const DEFAULT_ENCRYPTION_ALG = 'HS256';
/**
* Creates a new JSON web token por provided API key
*
* @param ApiKey $apiKey
* @param int $lifetime
* @return string
*/
public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME);
/**
* Refreshes a token and returns it with the new expiration
*
* @param string $jwt
* @param int $lifetime
* @return string
* @throws AuthenticationException If the token has expired
*/
public function refresh($jwt, $lifetime = self::DEFAULT_LIFETIME);
/**
* Verifies that certain JWT is valid
*
* @param string $jwt
* @return bool
*/
public function verify($jwt);
/**
* Decodes certain token and returns the payload
*
* @param string $jwt
* @return array
* @throws AuthenticationException If the token has expired
*/
public function getPayload($jwt);
}

View file

@ -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();
}
}

View file

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

View file

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

View 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,
]);
}
}

View 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);
}

View file

@ -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();
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,93 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Authentication;
use Firebase\JWT\JWT;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class JWTServiceTest extends TestCase
{
/**
* @var JWTService
*/
protected $service;
public function setUp()
{
$this->service = new JWTService(new AppOptions([
'name' => 'ShlinkTest',
'version' => '10000.3.1',
'secret_key' => 'foo',
]));
}
/**
* @test
*/
public function tokenIsProperlyCreated()
{
$id = 34;
$token = $this->service->create((new ApiKey())->setId($id));
$payload = (array) JWT::decode($token, 'foo', [JWTService::DEFAULT_ENCRYPTION_ALG]);
$this->assertGreaterThanOrEqual($payload['iat'], time());
$this->assertGreaterThan(time(), $payload['exp']);
$this->assertEquals($id, $payload['key']);
$this->assertEquals('auth', $payload['sub']);
$this->assertEquals('ShlinkTest:v10000.3.1', $payload['iss']);
}
/**
* @test
*/
public function refreshIncreasesExpiration()
{
$originalLifetime = 10;
$newLifetime = 30;
$originalPayload = ['exp' => time() + $originalLifetime];
$token = JWT::encode($originalPayload, 'foo');
$newToken = $this->service->refresh($token, $newLifetime);
$newPayload = (array) JWT::decode($newToken, 'foo', [JWTService::DEFAULT_ENCRYPTION_ALG]);
$this->assertGreaterThan($originalPayload['exp'], $newPayload['exp']);
}
/**
* @test
*/
public function verifyReturnsTrueWhenTheTokenIsCorrect()
{
$this->assertTrue($this->service->verify(JWT::encode([], 'foo')));
}
/**
* @test
*/
public function verifyReturnsFalseWhenTheTokenIsCorrect()
{
$this->assertFalse($this->service->verify('invalidToken'));
}
/**
* @test
*/
public function getPayloadWorksWithCorrectTokens()
{
$originalPayload = [
'exp' => time() + 10,
'sub' => 'testing',
];
$token = JWT::encode($originalPayload, 'foo');
$this->assertEquals($originalPayload, $this->service->getPayload($token));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Rest\Exception\AuthenticationException
*/
public function getPayloadThrowsExceptionWithIncorrectTokens()
{
$this->service->getPayload('invalidToken');
}
}

View file

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

View 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);
}
}

View file

@ -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());
}
}