From 270dbc60285dacf4202c9549e5b5f8ee4c750bcd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Aug 2016 12:40:31 +0200 Subject: [PATCH 01/16] Created new entity_manager configuration, dropping old database first level config key --- config/autoload/database.global.php | 15 -------------- config/autoload/entity-manager.global.php | 20 +++++++++++++++++++ .../src/Factory/EntityManagerFactory.php | 10 ++++++---- .../test/Factory/EntityManagerFactoryTest.php | 6 ++++-- module/Core/config/entity-manager.config.php | 12 +++++++++++ 5 files changed, 42 insertions(+), 21 deletions(-) delete mode 100644 config/autoload/database.global.php create mode 100644 config/autoload/entity-manager.global.php create mode 100644 module/Core/config/entity-manager.config.php diff --git a/config/autoload/database.global.php b/config/autoload/database.global.php deleted file mode 100644 index 4d05ea36..00000000 --- a/config/autoload/database.global.php +++ /dev/null @@ -1,15 +0,0 @@ - [ - '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' - ], - ], - -]; diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php new file mode 100644 index 00000000..87f99215 --- /dev/null +++ b/config/autoload/entity-manager.global.php @@ -0,0 +1,20 @@ + [ + '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', + ], + ], + ], + +]; diff --git a/module/Common/src/Factory/EntityManagerFactory.php b/module/Common/src/Factory/EntityManagerFactory.php index e42abb40..533aa322 100644 --- a/module/Common/src/Factory/EntityManagerFactory.php +++ b/module/Common/src/Factory/EntityManagerFactory.php @@ -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 )); diff --git a/module/Common/test/Factory/EntityManagerFactoryTest.php b/module/Common/test/Factory/EntityManagerFactoryTest.php index 53c839ed..2bad3c38 100644 --- a/module/Common/test/Factory/EntityManagerFactoryTest.php +++ b/module/Common/test/Factory/EntityManagerFactoryTest.php @@ -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', + ], ], ], ]]); diff --git a/module/Core/config/entity-manager.config.php b/module/Core/config/entity-manager.config.php new file mode 100644 index 00000000..e9359519 --- /dev/null +++ b/module/Core/config/entity-manager.config.php @@ -0,0 +1,12 @@ + [ + 'orm' => [ + 'entities_paths' => [ + __DIR__ . '/../src/Entity', + ], + ], + ], + +]; From 2767a14101567c305d5f3ef9eefacb84616b0362 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Aug 2016 12:50:44 +0200 Subject: [PATCH 02/16] Created ApiKey entity --- config/autoload/local.php.dist | 3 +- module/Rest/config/entity-manager.config.php | 12 ++ module/Rest/src/Entity/ApiKey.php | 117 +++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 module/Rest/config/entity-manager.config.php create mode 100644 module/Rest/src/Entity/ApiKey.php diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist index 75c8b9c2..cd1996b9 100644 --- a/config/autoload/local.php.dist +++ b/config/autoload/local.php.dist @@ -1,7 +1,8 @@ true, + 'debug' => true, 'config_cache_enabled' => false, + ]; diff --git a/module/Rest/config/entity-manager.config.php b/module/Rest/config/entity-manager.config.php new file mode 100644 index 00000000..e9359519 --- /dev/null +++ b/module/Rest/config/entity-manager.config.php @@ -0,0 +1,12 @@ + [ + 'orm' => [ + 'entities_paths' => [ + __DIR__ . '/../src/Entity', + ], + ], + ], + +]; diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php new file mode 100644 index 00000000..7dc1614f --- /dev/null +++ b/module/Rest/src/Entity/ApiKey.php @@ -0,0 +1,117 @@ +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; + } + + /** + * Tells if this api key is enabled and not expired + * + * @return bool + */ + public function isValid() + { + return $this->isEnabled() && ! $this->isExpired(); + } +} From 7b746f76b063f83d27e2afc8b2d24d57e43f8e4b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Aug 2016 13:18:27 +0200 Subject: [PATCH 03/16] Created APiKeyService and tests --- module/Rest/src/Action/AuthenticateAction.php | 6 +- module/Rest/src/Entity/ApiKey.php | 12 +- module/Rest/src/Service/ApiKeyService.php | 85 +++++++++++ .../src/Service/ApiKeyServiceInterface.php | 31 ++++ .../Rest/test/Service/ApiKeyServiceTest.php | 142 ++++++++++++++++++ 5 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 module/Rest/src/Service/ApiKeyService.php create mode 100644 module/Rest/src/Service/ApiKeyServiceInterface.php create mode 100644 module/Rest/test/Service/ApiKeyServiceTest.php diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 7d564e4f..37abbc56 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -44,10 +44,12 @@ 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'], $authData['username'], $authData['password'])) { 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); } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 7dc1614f..e0600e88 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -84,7 +84,7 @@ class ApiKey extends AbstractEntity return false; } - return $this->expirationDate >= new \DateTime(); + return $this->expirationDate < new \DateTime(); } /** @@ -105,6 +105,16 @@ class ApiKey extends AbstractEntity 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 * diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php new file mode 100644 index 00000000..3aa13b62 --- /dev/null +++ b/module/Rest/src/Service/ApiKeyService.php @@ -0,0 +1,85 @@ +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->em->getRepository(ApiKey::class)->findOneBy([ + 'key' => $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->em->getRepository(ApiKey::class)->findOneBy([ + 'key' => $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; + } +} diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php new file mode 100644 index 00000000..0c1f526b --- /dev/null +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -0,0 +1,31 @@ +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); + } +} From 99d7e6dd7d269f2508c138f0dba5dfcf15b63ba7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Aug 2016 13:24:06 +0200 Subject: [PATCH 04/16] Fixed AuthenticateAction not working with only one group of params --- module/Rest/src/Action/AuthenticateAction.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 37abbc56..7ac531df 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -44,7 +44,7 @@ class AuthenticateAction extends AbstractRestAction public function dispatch(Request $request, Response $response, callable $out = null) { $authData = $request->getParsedBody(); - if (! isset($authData['apiKey'], $authData['username'], $authData['password'])) { + if (! isset($authData['apiKey']) && ! isset($authData['username'], $authData['password'])) { return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, 'message' => $this->translator->translate( From 74777c2234f1459d201a9d84aabf257e0b77204f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Aug 2016 18:07:48 +0200 Subject: [PATCH 05/16] Created command to generate a new api key --- module/CLI/config/cli.config.php | 1 + module/CLI/config/dependencies.config.php | 1 + .../CLI/src/Command/Api/DisableKeyCommand.php | 6 ++ .../src/Command/Api/GenerateKeyCommand.php | 56 +++++++++++++++++++ .../Command/Api/GenerateKeyCommandTest.php | 55 ++++++++++++++++++ module/Rest/config/dependencies.config.php | 1 + module/Rest/src/Entity/ApiKey.php | 10 ++++ 7 files changed, 130 insertions(+) create mode 100644 module/CLI/src/Command/Api/DisableKeyCommand.php create mode 100644 module/CLI/src/Command/Api/GenerateKeyCommand.php create mode 100644 module/CLI/test/Command/Api/GenerateKeyCommandTest.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index a9b13a72..3d9ff486 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -11,6 +11,7 @@ return [ Command\GetVisitsCommand::class, Command\ProcessVisitsCommand::class, Command\Config\GenerateCharsetCommand::class, + Command\Api\GenerateKeyCommand::class, ] ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index d99f68d9..ca671731 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -17,6 +17,7 @@ return [ Command\ProcessVisitsCommand::class => AnnotatedFactory::class, Command\ProcessVisitsCommand::class => AnnotatedFactory::class, Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class, + Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class, ], ], diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php new file mode 100644 index 00000000..3e52908e --- /dev/null +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -0,0 +1,6 @@ +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(': %s', $apiKey)); + } +} diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php new file mode 100644 index 00000000..b0d44a56 --- /dev/null +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -0,0 +1,55 @@ +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', + ]); + } +} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index e04c8ba0..8f6dbbfb 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -10,6 +10,7 @@ return [ 'dependencies' => [ 'factories' => [ Service\RestTokenService::class => AnnotatedFactory::class, + Service\ApiKeyService::class => AnnotatedFactory::class, Action\AuthenticateAction::class => AnnotatedFactory::class, Action\CreateShortcodeAction::class => AnnotatedFactory::class, diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index e0600e88..0f458c11 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -124,4 +124,14 @@ class ApiKey extends AbstractEntity { return $this->isEnabled() && ! $this->isExpired(); } + + /** + * The string repesentation of an API key is the key itself + * + * @return string + */ + public function __toString() + { + return $this->getKey(); + } } From dd1bc49b7927c0e5587d82a4f71918d81557f089 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Aug 2016 18:08:09 +0200 Subject: [PATCH 06/16] Added method to ApiKeyService to list api keys --- module/Rest/src/Service/ApiKeyService.php | 12 +++++++++ .../src/Service/ApiKeyServiceInterface.php | 8 ++++++ .../Rest/test/Service/ApiKeyServiceTest.php | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 3aa13b62..00fddfad 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -82,4 +82,16 @@ class ApiKeyService implements ApiKeyServiceInterface $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); + } } diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 0c1f526b..84c856ce 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -28,4 +28,12 @@ interface ApiKeyServiceInterface * @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); } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 7375c83d..7ab46432 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -139,4 +139,30 @@ class ApiKeyServiceTest extends TestCase $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); + } } From c5382b2a7f352d566762688d78860e7b08a293dc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Aug 2016 18:26:07 +0200 Subject: [PATCH 07/16] Created DisableKeyCommand --- module/CLI/config/cli.config.php | 1 + module/CLI/config/dependencies.config.php | 1 + .../CLI/src/Command/Api/DisableKeyCommand.php | 58 ++++++++++++++++- .../Command/Api/DisableKeyCommandTest.php | 62 +++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 module/CLI/test/Command/Api/DisableKeyCommandTest.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 3d9ff486..5739228c 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -12,6 +12,7 @@ return [ Command\ProcessVisitsCommand::class, Command\Config\GenerateCharsetCommand::class, Command\Api\GenerateKeyCommand::class, + Command\Api\DisableKeyCommand::class, ] ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index ca671731..03e3bd2c 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -18,6 +18,7 @@ return [ Command\ProcessVisitsCommand::class => AnnotatedFactory::class, Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class, Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class, + Command\Api\DisableKeyCommand::class => AnnotatedFactory::class, ], ], diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 3e52908e..738b8b43 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -1,6 +1,62 @@ 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'), + '' . $apiKey . '' + )); + } catch (\InvalidArgumentException $e) { + $output->writeln(sprintf( + '' . $this->translator->translate('API key "%s" does not exist.') . '', + $apiKey + )); + } + } } diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php new file mode 100644 index 00000000..68d5f8c2 --- /dev/null +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -0,0 +1,62 @@ +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); + } +} From 289db45f2722405122b4b6b2fc9bbe3d94f25c08 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Aug 2016 18:50:50 +0200 Subject: [PATCH 08/16] Created ListKeysCommand --- module/CLI/config/cli.config.php | 1 + module/CLI/config/dependencies.config.php | 1 + .../CLI/src/Command/Api/ListKeysCommand.php | 108 ++++++++++++++++++ .../test/Command/Api/ListKeysCommandTest.php | 62 ++++++++++ 4 files changed, 172 insertions(+) create mode 100644 module/CLI/src/Command/Api/ListKeysCommand.php create mode 100644 module/CLI/test/Command/Api/ListKeysCommandTest.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 5739228c..35624e51 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -13,6 +13,7 @@ return [ Command\Config\GenerateCharsetCommand::class, Command\Api\GenerateKeyCommand::class, Command\Api\DisableKeyCommand::class, + Command\Api\ListKeysCommand::class, ] ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 03e3bd2c..f3257fc6 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -19,6 +19,7 @@ return [ Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class, Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class, Command\Api\DisableKeyCommand::class => AnnotatedFactory::class, + Command\Api\ListKeysCommand::class => AnnotatedFactory::class, ], ], diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php new file mode 100644 index 00000000..e6f70ec0 --- /dev/null +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -0,0 +1,108 @@ +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('%s', $string); + } + + /** + * @param string $string + * @return string + */ + protected function getSuccessString($string) + { + return sprintf('%s', $string); + } +} diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php new file mode 100644 index 00000000..a7f58257 --- /dev/null +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -0,0 +1,62 @@ +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, + ]); + } +} From 1d92e87d5025aa83696851a156f727ce9586a3fa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 10:26:34 +0200 Subject: [PATCH 09/16] Updated AuthenticateAction to use the APiKeyService instead of the RestTokenService --- module/Rest/src/Action/AuthenticateAction.php | 37 ++++++++++--------- module/Rest/src/Util/RestUtils.php | 1 + .../test/Action/AuthenticateActionTest.php | 31 +++++++--------- 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 7ac531df..0fcac3f9 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -4,35 +4,34 @@ namespace Shlinkio\Shlink\Rest\Action; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; 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\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; /** * AuthenticateAction constructor. - * @param RestTokenServiceInterface|RestTokenService $restTokenService + * @param ApiKeyServiceInterface|ApiKeyService $apiKeyService * @param TranslatorInterface $translator * - * @Inject({RestTokenService::class, "translator"}) + * @Inject({ApiKeyService::class, "translator"}) */ - public function __construct(RestTokenServiceInterface $restTokenService, TranslatorInterface $translator) + public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator) { - $this->restTokenService = $restTokenService; $this->translator = $translator; + $this->apiKeyService = $apiKeyService; } /** @@ -44,7 +43,7 @@ class AuthenticateAction extends AbstractRestAction public function dispatch(Request $request, Response $response, callable $out = null) { $authData = $request->getParsedBody(); - if (! isset($authData['apiKey']) && ! isset($authData['username'], $authData['password'])) { + if (! isset($authData['apiKey'])) { return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, 'message' => $this->translator->translate( @@ -53,14 +52,16 @@ class AuthenticateAction extends AbstractRestAction ], 400); } - try { - $token = $this->restTokenService->createToken($authData['username'], $authData['password']); - return new JsonResponse(['token' => $token->getToken()]); - } catch (AuthenticationException $e) { + // Authenticate using provided API key + if (! $this->apiKeyService->check($authData['apiKey'])) { 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); } + + // TODO Generate a JSON Web Token that will be used for authorization in next requests + + return new JsonResponse(['token' => '']); } } diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index b67491ed..8326487e 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -12,6 +12,7 @@ class RestUtils const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT'; const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN'; + const INVALID_API_KEY_ERROR = 'INVALID_API_KEY'; const NOT_FOUND_ERROR = 'NOT_FOUND'; const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; diff --git a/module/Rest/test/Action/AuthenticateActionTest.php b/module/Rest/test/Action/AuthenticateActionTest.php index d61da421..57522852 100644 --- a/module/Rest/test/Action/AuthenticateActionTest.php +++ b/module/Rest/test/Action/AuthenticateActionTest.php @@ -3,10 +3,8 @@ 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\Service\ApiKeyService; use Zend\Diactoros\Response; use Zend\Diactoros\ServerRequestFactory; use Zend\I18n\Translator\Translator; @@ -20,12 +18,12 @@ class AuthenticateActionTest extends TestCase /** * @var ObjectProphecy */ - protected $tokenService; + protected $apiKeyService; 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->action = new AuthenticateAction($this->apiKeyService->reveal(), Translator::factory([])); } /** @@ -40,34 +38,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->check('foo')->willReturn(true) + ->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->check('foo')->willReturn(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()); From a60080b1ce6f7835716822d5936999831af4d411 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 14:44:33 +0200 Subject: [PATCH 10/16] Created JWTService and related classes --- .env.dist | 1 + composer.json | 3 +- config/autoload/app_options.global.php | 10 ++ module/Core/config/app_options.config.php | 6 + module/Core/config/dependencies.config.php | 3 + module/Core/src/Options/AppOptions.php | 97 +++++++++++++++ module/Rest/src/Action/AuthenticateAction.php | 1 + module/Rest/src/Authentication/JWTService.php | 110 ++++++++++++++++++ .../Authentication/JWTServiceInterface.php | 47 ++++++++ .../src/Exception/AuthenticationException.php | 5 + .../test/Authentication/JWTServiceTest.php | 93 +++++++++++++++ 11 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 config/autoload/app_options.global.php create mode 100644 module/Core/config/app_options.config.php create mode 100644 module/Core/src/Options/AppOptions.php create mode 100644 module/Rest/src/Authentication/JWTService.php create mode 100644 module/Rest/src/Authentication/JWTServiceInterface.php create mode 100644 module/Rest/test/Authentication/JWTServiceTest.php diff --git a/.env.dist b/.env.dist index 9b175618..d56f522f 100644 --- a/.env.dist +++ b/.env.dist @@ -1,5 +1,6 @@ # Application APP_ENV= +SECRET_KEY= SHORTENED_URL_SCHEMA= SHORTENED_URL_HOSTNAME= SHORTCODE_CHARS= diff --git a/composer.json b/composer.json index 829bb949..72391371 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/autoload/app_options.global.php b/config/autoload/app_options.global.php new file mode 100644 index 00000000..4db642ce --- /dev/null +++ b/config/autoload/app_options.global.php @@ -0,0 +1,10 @@ + [ + 'name' => 'Shlink', + 'version' => '1.1.0', + 'secret_key' => env('SECRET_KEY'), + ], + +]; diff --git a/module/Core/config/app_options.config.php b/module/Core/config/app_options.config.php new file mode 100644 index 00000000..bf224541 --- /dev/null +++ b/module/Core/config/app_options.config.php @@ -0,0 +1,6 @@ + [], + +]; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 9bcacbfa..27983069 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -1,12 +1,15 @@ [ 'factories' => [ + AppOptions::class => AnnotatedFactory::class, + // Services Service\UrlShortener::class => AnnotatedFactory::class, Service\VisitsTracker::class => AnnotatedFactory::class, diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php new file mode 100644 index 00000000..6ee1322c --- /dev/null +++ b/module/Core/src/Options/AppOptions.php @@ -0,0 +1,97 @@ +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); + } +} diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 0fcac3f9..093e935f 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -2,6 +2,7 @@ namespace Shlinkio\Shlink\Rest\Action; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Firebase\JWT\JWT; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Rest\Service\ApiKeyService; diff --git a/module/Rest/src/Authentication/JWTService.php b/module/Rest/src/Authentication/JWTService.php new file mode 100644 index 00000000..bc1647c2 --- /dev/null +++ b/module/Rest/src/Authentication/JWTService.php @@ -0,0 +1,110 @@ +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]); + } +} diff --git a/module/Rest/src/Authentication/JWTServiceInterface.php b/module/Rest/src/Authentication/JWTServiceInterface.php new file mode 100644 index 00000000..278e6c67 --- /dev/null +++ b/module/Rest/src/Authentication/JWTServiceInterface.php @@ -0,0 +1,47 @@ + "%s". Password -> "%s"', $username, $password)); } + + public static function expiredJWT(\Exception $prev = null) + { + return new self('The token has expired.', -1, $prev); + } } diff --git a/module/Rest/test/Authentication/JWTServiceTest.php b/module/Rest/test/Authentication/JWTServiceTest.php new file mode 100644 index 00000000..ede0b6c6 --- /dev/null +++ b/module/Rest/test/Authentication/JWTServiceTest.php @@ -0,0 +1,93 @@ +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'); + } +} From 9573e9f4ef8187d8838205191902e663264e3cd6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 19:13:40 +0200 Subject: [PATCH 11/16] Updated AuthenticateAction to generate and return a JWT --- module/Rest/config/dependencies.config.php | 2 ++ module/Rest/src/Action/AuthenticateAction.php | 26 ++++++++++++++----- module/Rest/src/Authentication/JWTService.php | 3 +++ module/Rest/src/Service/ApiKeyService.php | 21 ++++++++++----- .../src/Service/ApiKeyServiceInterface.php | 8 ++++++ .../test/Action/AuthenticateActionTest.php | 21 +++++++++++---- 6 files changed, 63 insertions(+), 18 deletions(-) diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 8f6dbbfb..e0cc13f7 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -1,6 +1,7 @@ [ 'factories' => [ + JWTService::class => AnnotatedFactory::class, Service\RestTokenService::class => AnnotatedFactory::class, Service\ApiKeyService::class => AnnotatedFactory::class, diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 093e935f..020a0fb5 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -5,6 +5,8 @@ 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\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; @@ -21,18 +23,27 @@ class AuthenticateAction extends AbstractRestAction * @var ApiKeyService|ApiKeyServiceInterface */ private $apiKeyService; + /** + * @var JWTServiceInterface + */ + private $jwtService; /** * AuthenticateAction constructor. * @param ApiKeyServiceInterface|ApiKeyService $apiKeyService + * @param JWTServiceInterface|JWTService $jwtService * @param TranslatorInterface $translator * - * @Inject({ApiKeyService::class, "translator"}) + * @Inject({ApiKeyService::class, JWTService::class, "translator"}) */ - public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator) - { + public function __construct( + ApiKeyServiceInterface $apiKeyService, + JWTServiceInterface $jwtService, + TranslatorInterface $translator + ) { $this->translator = $translator; $this->apiKeyService = $apiKeyService; + $this->jwtService = $jwtService; } /** @@ -54,15 +65,16 @@ class AuthenticateAction extends AbstractRestAction } // Authenticate using provided API key - if (! $this->apiKeyService->check($authData['apiKey'])) { + $apiKey = $this->apiKeyService->getByKey($authData['apiKey']); + if (! $apiKey->isValid()) { return new JsonResponse([ 'error' => RestUtils::INVALID_API_KEY_ERROR, 'message' => $this->translator->translate('Provided API key does not exist or is invalid.'), ], 401); } - // TODO Generate a JSON Web Token that will be used for authorization in next requests - - return new JsonResponse(['token' => '']); + // Generate a JSON Web Token that will be used for authorization in next requests + $token = $this->jwtService->create($apiKey); + return new JsonResponse(['token' => $token]); } } diff --git a/module/Rest/src/Authentication/JWTService.php b/module/Rest/src/Authentication/JWTService.php index bc1647c2..ee252606 100644 --- a/module/Rest/src/Authentication/JWTService.php +++ b/module/Rest/src/Authentication/JWTService.php @@ -1,6 +1,7 @@ em->getRepository(ApiKey::class)->findOneBy([ - 'key' => $key, - ]); + $apiKey = $this->getByKey($key); if (! isset($apiKey)) { return false; } @@ -71,9 +69,7 @@ class ApiKeyService implements ApiKeyServiceInterface public function disable($key) { /** @var ApiKey $apiKey */ - $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([ - 'key' => $key, - ]); + $apiKey = $this->getByKey($key); if (! isset($apiKey)) { throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key)); } @@ -94,4 +90,17 @@ class ApiKeyService implements ApiKeyServiceInterface $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, + ]); + } } diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 84c856ce..e1b8ce53 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -36,4 +36,12 @@ interface ApiKeyServiceInterface * @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); } diff --git a/module/Rest/test/Action/AuthenticateActionTest.php b/module/Rest/test/Action/AuthenticateActionTest.php index 57522852..240b60a6 100644 --- a/module/Rest/test/Action/AuthenticateActionTest.php +++ b/module/Rest/test/Action/AuthenticateActionTest.php @@ -4,6 +4,8 @@ namespace ShlinkioTest\Shlink\Rest\Action; use PHPUnit_Framework_TestCase as TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Rest\Action\AuthenticateAction; +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; @@ -19,11 +21,20 @@ class AuthenticateActionTest extends TestCase * @var ObjectProphecy */ protected $apiKeyService; + /** + * @var ObjectProphecy + */ + protected $jwtService; public function setUp() { $this->apiKeyService = $this->prophesize(ApiKeyService::class); - $this->action = new AuthenticateAction($this->apiKeyService->reveal(), Translator::factory([])); + $this->jwtService = $this->prophesize(JWTService::class); + $this->action = new AuthenticateAction( + $this->apiKeyService->reveal(), + $this->jwtService->reveal(), + Translator::factory([]) + ); } /** @@ -40,8 +51,8 @@ class AuthenticateActionTest extends TestCase */ public function properApiKeyReturnsTokenInResponse() { - $this->apiKeyService->check('foo')->willReturn(true) - ->shouldBeCalledTimes(1); + $this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setId(5)) + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withParsedBody([ 'apiKey' => 'foo', @@ -58,8 +69,8 @@ class AuthenticateActionTest extends TestCase */ public function invalidApiKeyReturnsErrorResponse() { - $this->apiKeyService->check('foo')->willReturn(false) - ->shouldBeCalledTimes(1); + $this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setEnabled(false)) + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withParsedBody([ 'apiKey' => 'foo', From 7b0beb3b8c76eaed206ce896b8e7800fb6de80a8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 19:53:14 +0200 Subject: [PATCH 12/16] Updated CheckAuthenticationMiddleware to work with JWT and the Authorization header --- .../CheckAuthenticationMiddleware.php | 65 +++++++++++++------ module/Rest/src/Util/RestUtils.php | 1 + .../CheckAuthenticationMiddlewareTest.php | 57 ++++++++++++---- 3 files changed, 91 insertions(+), 32 deletions(-) diff --git a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php index 1ad53c4b..39c670e6 100644 --- a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php @@ -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); } diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 8326487e..4f4332c4 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -12,6 +12,7 @@ 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'; diff --git a/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php index 650d4d2f..5d8dce7c 100644 --- a/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php @@ -4,8 +4,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 +20,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 +92,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 +135,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()); @@ -120,14 +152,15 @@ 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($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); } From 258f954a388337ca86a40f90c42b95b6491be38e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 19:57:23 +0200 Subject: [PATCH 13/16] Deleted rest token related classes --- module/Core/src/Entity/RestToken.php | 103 ------------------ module/Rest/config/dependencies.config.php | 1 - module/Rest/src/Service/RestTokenService.php | 98 ----------------- .../src/Service/RestTokenServiceInterface.php | 32 ------ .../CheckAuthenticationMiddlewareTest.php | 2 - .../test/Service/RestTokenServiceTest.php | 93 ---------------- 6 files changed, 329 deletions(-) delete mode 100644 module/Core/src/Entity/RestToken.php delete mode 100644 module/Rest/src/Service/RestTokenService.php delete mode 100644 module/Rest/src/Service/RestTokenServiceInterface.php delete mode 100644 module/Rest/test/Service/RestTokenServiceTest.php diff --git a/module/Core/src/Entity/RestToken.php b/module/Core/src/Entity/RestToken.php deleted file mode 100644 index 865c83b9..00000000 --- a/module/Core/src/Entity/RestToken.php +++ /dev/null @@ -1,103 +0,0 @@ -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()); - } -} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index e0cc13f7..685de2c3 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -11,7 +11,6 @@ return [ 'dependencies' => [ 'factories' => [ JWTService::class => AnnotatedFactory::class, - Service\RestTokenService::class => AnnotatedFactory::class, Service\ApiKeyService::class => AnnotatedFactory::class, Action\AuthenticateAction::class => AnnotatedFactory::class, diff --git a/module/Rest/src/Service/RestTokenService.php b/module/Rest/src/Service/RestTokenService.php deleted file mode 100644 index b9dd4a9d..00000000 --- a/module/Rest/src/Service/RestTokenService.php +++ /dev/null @@ -1,98 +0,0 @@ -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(); - } -} diff --git a/module/Rest/src/Service/RestTokenServiceInterface.php b/module/Rest/src/Service/RestTokenServiceInterface.php deleted file mode 100644 index 1e03cbaa..00000000 --- a/module/Rest/src/Service/RestTokenServiceInterface.php +++ /dev/null @@ -1,32 +0,0 @@ -setExpirationDate((new \DateTime())->add(new \DateInterval('P1D'))); $request = ServerRequestFactory::fromGlobals()->withAttribute( RouteResult::class, RouteResult::fromRouteMatch('bar', 'foo', []) diff --git a/module/Rest/test/Service/RestTokenServiceTest.php b/module/Rest/test/Service/RestTokenServiceTest.php deleted file mode 100644 index d4487ff1..00000000 --- a/module/Rest/test/Service/RestTokenServiceTest.php +++ /dev/null @@ -1,93 +0,0 @@ -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()); - } -} From 2a089f05b17a816e61d26d291c169b583b683d23 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 20:21:38 +0200 Subject: [PATCH 14/16] Updated languages --- module/CLI/lang/es.mo | Bin 4071 -> 5096 bytes module/CLI/lang/es.po | 44 +++++++++++++++++++++++++++++++++++++++-- module/Rest/lang/es.mo | Bin 1897 -> 2200 bytes module/Rest/lang/es.po | 34 +++++++++++++++++++++++-------- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo index b51c40cb39c8ec9d16ff4e818f2aa38a26f83482..1f88545ff8ea4ea09889369e6d732a63b4a2e971 100644 GIT binary patch delta 1982 zcmZwGUrZcD9Ki8`9zrQa3T^3MIz&X z?fboN@#pr;lA;aM_s~DAQ|c%VZs0;2$tcx{kKhw{0w?e-%;61`@AfuS9YuNn1w4Ww zO1#go75~I`%-lm)YEgA^)53#2xE1^HUVI#P<1}u;mrw#+!Z+|u?82>$O6|mlu^FF1 zS^H`1z!z~p&f`XW8#TU*EzGaJ;6?&}iw^#Xo3W`$sYAFO58yO1q+UUJ|232yS;7bK z2b2Z;jR$cfvu(%6Z~{-_0A9gu_!V|Czxt^*a0?|s7Z=%)e%ysw+)~fhqhyk2a139? zDf}A8v4d6b!!!6GmT@m$!{fMuC$WRo%TAxg#U0#S;)b%QC6wB{gYx00wg10E$?zAH zl{ZlzVyP~a!!wBYg8+43_;`PF6QPrc9O1b&Xgm>~=KE{jiM4&|)8j;HY>%;G=o?7y7WEGI=) zIE^xJ4vD2cK;Bi?QR6pQhY!)IR$3lN+kG`SO2>jQMwr|Rj_hUl_*X_AaI`K8Dqmh$$}4@+Gg zIweb+{B-0Hafnr4`lNd40jb$0x@@IvZ-3?RhMOZZllq)3>+asTTf3o+br2@nUi9L` zQAcWny|JE;!g(9{W$k*gnf0w(>B)TGldJv{YZK_&%Z?hqIPXO!@xnm6Cb5;jGJTD; z!Ag6><*uh}U>RpBqXTY1o~cjI|BYY1A=WR#U7-8{gM)A&e4z>e;C{ zeMSTxCIzca$#}keuo@?JmTxqa>lUr=$J(3IVZhpJG1uOactzjUvsOnoDMo>H9rYaH zRufOc)qK;(l8*KQeWBpx3+cS8FV+HCN!>d7RLqisvM#lyxy?L1qvyityh)tO@y1(? zhxEar(cx@mvT1+qeq?!}>Am`6Q%2`~Q?lBvt|6^T5LWA99d&g5AXRZ~Trjhq?= zWvu1e^VN|wQc$#|yWh~Im8(?JIBN225Js}1$r*|6hk4@{!Z=amak6q%-&t}sp~~%+ zt^ujB(MjlH+QCyFtaw$ zv5mMZ6|J9`*irx8ZP|*o{fuOIF5Mu$^o?F9lF3@ekJi>jT&g1nKBDXirFfZlipF0k Xdd$Yz4!NcqLm30jQE)1^TmSeMvcP;n delta 987 zcmYk*O=uHA6u|M>nhz6g^W{fsYdWd6R-sA)QK*P1wuM4!t3^=3!{P?3EDdCP5iPli zC!q{Ih#&}h5RW+uUbKP-4=r81qC<9nGqbZh^WIFpANkhP_?!+c zDoP($H`j=zR0-WSZj|y4rS{`6p2aITh7WNBzqFqBg&0Ho44%UVcHnDF;|lUq>)dwZ z5A0%?`a?wqX2MF{$6oBj7ns2%jNwPrxQ6n9P$YOSg~w@YY{yAFh1c)|E@BjyQ094$ z638bUV1Cuj(>NWYcnqg-FWy0ke4*9<9A(@Glm)Ef0sM(6vK8H_RGxMxgUWasD|i=2 z@f{w-#4Z+zgE-CnYM#m-T*fSZMS@gYD5(oG+W=-!A{<89vq_Zk>nK~diOiw87&L_a zNSCrvBA>-EoJW4@4Yx*_$_f=pNrq8mMNMN0ui^!Cv4F2oUi^h8aT6s&r9`lR36%a@ zDDOW(380A@-=c-GtML4h~?B23za@GYHf_gxk*#wS#=3eB%;P{Q%TD|IMw|3ojXKLP@wjHNl^*mL%DGk@v-b}4Nr!QTsc+G|AZOc50 z{f-uNu`pCD7!%L8>c!^E_~W+bv-X%}Ry&TGT;gfg|G4egdVESd)qiuCY%7q diff --git a/module/CLI/lang/es.po b/module/CLI/lang/es.po index 8bc5d32b..968701ea 100644 --- a/module/CLI/lang/es.po +++ b/module/CLI/lang/es.po @@ -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 \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\". " diff --git a/module/Rest/lang/es.mo b/module/Rest/lang/es.mo index 1bfa9aefbc137b30b9e4597348190e0ec80aacf7..915466d15c785a69440ae36b00e4dc36dce073a8 100644 GIT binary patch delta 823 zcmY+B&1+LZ6vZb=lWP6Iiui$2uhs7Kk(O%O?i%W%C?ZPnYchWpMW`DW zf*TzKH!iYsUvMXgLHrBci6H30g)4C@o_R@&9k~2v9&^tIX9vpiqu=b)( zpdO(nQD0DDQ9+2q;3@DKxB#96KY|hX1FV6EibAY_m%;G;Id~d;4;}-*gLUvPcph9X zVNOwOhzHnA;ltCi5bwY@;05q*Whnd_yn^#*@D6xp9L?ZkunK+vgYnI#vlMI{lHMxl^U`R_U6CI9^Lc4ywcJx;uK)t-7=jtJF>3?UjxAooaEJr+?P zOh5)l;US!bY4`w3@B`Y=ju4H&6&SwXg9Gps=HN9k!2Nd-<51P zD_KQ)+UH8t&pAHCs`XRsDwR2F{WOoUMYA8@OwM!W2bwb_*WHS%xzjk>SV`FCJw7q4 Qxu@m-r7Cg*{ohvf4MyZ~wg3PC diff --git a/module/Rest/lang/es.po b/module/Rest/lang/es.po index 62f76e1d..c911722c 100644 --- a/module/Rest/lang/es.po +++ b/module/Rest/lang/es.po @@ -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 \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\"" From 57bc681b9e6ae114c037fd5d0e3d6e646a4cb477 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 20:30:19 +0200 Subject: [PATCH 15/16] Created command to generate a random secret key string --- module/CLI/config/cli.config.php | 1 + module/CLI/config/dependencies.config.php | 1 + .../Command/Config/GenerateSecretCommand.php | 45 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 module/CLI/src/Command/Config/GenerateSecretCommand.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 35624e51..adc49fa9 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -11,6 +11,7 @@ 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, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index f3257fc6..18e05dcb 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -17,6 +17,7 @@ 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, diff --git a/module/CLI/src/Command/Config/GenerateSecretCommand.php b/module/CLI/src/Command/Config/GenerateSecretCommand.php new file mode 100644 index 00000000..bef5c86a --- /dev/null +++ b/module/CLI/src/Command/Config/GenerateSecretCommand.php @@ -0,0 +1,45 @@ +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(' %s', $secret)); + } +} From 80d8c32881aff29eed9839a6e47fc7845046d9cf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Aug 2016 20:47:43 +0200 Subject: [PATCH 16/16] Removed rest auth env vars --- .env.dist | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.env.dist b/.env.dist index d56f522f..9ecb9fe0 100644 --- a/.env.dist +++ b/.env.dist @@ -13,7 +13,3 @@ CLI_LOCALE= DB_USER= DB_PASSWORD= DB_NAME= - -# Rest authentication -REST_USER= -REST_PASSWORD=