diff --git a/.env.dist b/.env.dist index 9b175618..9ecb9fe0 100644 --- a/.env.dist +++ b/.env.dist @@ -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= 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/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/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/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index a9b13a72..adc49fa9 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -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, ] ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index d99f68d9..18e05dcb 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -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, ], ], diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo index b51c40cb..1f88545f 100644 Binary files a/module/CLI/lang/es.mo and b/module/CLI/lang/es.mo differ 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/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php new file mode 100644 index 00000000..738b8b43 --- /dev/null +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -0,0 +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/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php new file mode 100644 index 00000000..75d94e65 --- /dev/null +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -0,0 +1,56 @@ +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/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/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)); + } +} 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); + } +} 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/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, + ]); + } +} 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/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/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', + ], + ], + ], + +]; 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/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/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index e04c8ba0..685de2c3 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -1,6 +1,7 @@ [ '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, 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/lang/es.mo b/module/Rest/lang/es.mo index 1bfa9aef..915466d1 100644 Binary files a/module/Rest/lang/es.mo and b/module/Rest/lang/es.mo differ 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\"" diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 7d564e4f..020a0fb5 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -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]); } } diff --git a/module/Rest/src/Authentication/JWTService.php b/module/Rest/src/Authentication/JWTService.php new file mode 100644 index 00000000..ee252606 --- /dev/null +++ b/module/Rest/src/Authentication/JWTService.php @@ -0,0 +1,113 @@ +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 @@ +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(); + } +} diff --git a/module/Rest/src/Exception/AuthenticationException.php b/module/Rest/src/Exception/AuthenticationException.php index ec4d0a4b..3a77a5d3 100644 --- a/module/Rest/src/Exception/AuthenticationException.php +++ b/module/Rest/src/Exception/AuthenticationException.php @@ -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); + } } 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/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php new file mode 100644 index 00000000..c9d7f7fb --- /dev/null +++ b/module/Rest/src/Service/ApiKeyService.php @@ -0,0 +1,106 @@ +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, + ]); + } +} diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php new file mode 100644 index 00000000..e1b8ce53 --- /dev/null +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -0,0 +1,47 @@ +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 @@ -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()); 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'); + } +} diff --git a/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php index 650d4d2f..c36c0d02 100644 --- a/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php @@ -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); } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php new file mode 100644 index 00000000..7ab46432 --- /dev/null +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -0,0 +1,168 @@ +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); + } +} 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()); - } -}