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/.phpstorm.meta.php b/.phpstorm.meta.php new file mode 100644 index 00000000..1a5e12de --- /dev/null +++ b/.phpstorm.meta.php @@ -0,0 +1,19 @@ + [ + '' == '@', + ], +]; diff --git a/.travis-php.ini b/.travis-php.ini new file mode 100644 index 00000000..c9a2ff0c --- /dev/null +++ b/.travis-php.ini @@ -0,0 +1 @@ +extension="memcached.so" \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 52312334..83d37309 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,8 @@ php: - 7 - 7.1 +before_install: phpenv config-add .travis-php.ini + before_script: - composer self-update - composer install --no-interaction diff --git a/composer.json b/composer.json index 829bb949..1f9bf7bd 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,11 @@ "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", + "monolog/monolog": "^1.21", + "theorchard/monolog-cascade": "^0.4", + "endroid/qrcode": "^1.7" }, "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/config/autoload/logger.global.php b/config/autoload/logger.global.php new file mode 100644 index 00000000..2a56bdad --- /dev/null +++ b/config/autoload/logger.global.php @@ -0,0 +1,32 @@ + [ + 'formatters' => [ + 'dashed' => [ + 'format' => '[%datetime%] %channel%.%level_name% - %message% %context%' . PHP_EOL, + 'include_stacktraces' => true, + ], + ], + + 'handlers' => [ + 'rotating_file_handler' => [ + 'class' => RotatingFileHandler::class, + 'level' => Logger::INFO, + 'filename' => 'data/log/shlink_log.log', + 'max_files' => 30, + 'formatter' => 'dashed', + ], + ], + + 'loggers' => [ + 'Shlink' => [ + 'handlers' => ['rotating_file_handler'], + ], + ], + ], + +]; diff --git a/config/autoload/logger.local.php.dist b/config/autoload/logger.local.php.dist new file mode 100644 index 00000000..951a3af0 --- /dev/null +++ b/config/autoload/logger.local.php.dist @@ -0,0 +1,14 @@ + [ + 'handlers' => [ + 'rotating_file_handler' => [ + 'level' => Logger::DEBUG, + ], + ], + ], + +]; diff --git a/data/docs/installation.md b/data/docs/installation.md deleted file mode 100644 index 67e241fc..00000000 --- a/data/docs/installation.md +++ /dev/null @@ -1,19 +0,0 @@ -### Installation steps - -- Define ENV vars in apache or nginx: - - SHORTENED_URL_SCHEMA: http|https - - SHORTENED_URL_HOSTNAME: Short domain - - SHORTCODE_CHARS: The char set used to generate short codes (defaults to **123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ**, but a new one can be generated with the `config:generate-charset` command) - - DB_USER: MySQL database user - - DB_PASSWORD: MySQL database password - - REST_USER: Username for REST authentication - - REST_PASSWORD: Password for REST authentication - - DB_NAME: MySQL database name (defaults to **shlink**) - - DEFAULT_LOCALE: Language in which web requests (browser and REST) will be returned if no `Accept-Language` header is sent (defaults to **en**) - - CLI_LOCALE: Language in which console command messages will be displayed (defaults to **en**) -- Create database (`vendor/bin/doctrine orm:schema-tool:create`) -- Add write permissions to `data` directory -- Create doctrine proxies (`vendor/bin/doctrine orm:generate-proxies`) -- Create symlink to bin/cli as `shlink` in /usr/local/bin (linux only. Optional) - -Supported languages: es and en diff --git a/data/docs/rest.md b/data/docs/rest.md deleted file mode 100644 index bbc9a565..00000000 --- a/data/docs/rest.md +++ /dev/null @@ -1,289 +0,0 @@ - -# REST API documentation - -## Error management - -Statuses: - -* 400 -> controlled error -* 401 -> authentication error -* 500 -> unexpected error - -[TODO] - -## Authentication - -Once you have called to the authentication endpoint for the first time (see below) yopu will get an authentication token. - -You will have to send that token in the `X-Auth-Token` header on any later request or you will get an authentication error. - -## Language - -In order to set the application language, you have to pass it by using the `Accept-Language` header. - -If not provided or provided language is not supported, english (en_US) will be used. - -## Endpoints - -#### Authenticate - -**REQUEST** - -* `POST` -> `/rest/authenticate` -* Params: - * username: `string` - * password: `string` - -**SUCCESS RESPONSE** - -```json -{ - "token": "9f741eb0-33d7-4c56-b8f7-3719e9929946" -} -``` - -**ERROR RESPONSE** - -```json -{ - "error": "INVALID_ARGUMENT", - "message": "You have to provide both \"username\" and \"password\"" -} -``` - -Posible errors: - -* **INVALID_ARGUMENT**: Username or password were not provided. -* **INVALID_CREDENTIALS**: Username or password are incorrect. - - -#### Create shortcode - -**REQUEST** - -* `POST` -> `/rest/short-codes` -* Params: - * longUrl: `string` -> The URL to shorten -* Headers: - * X-Auth-Token: `string` -> The token provided in the authentication request - -**SUCCESS RESPONSE** - -```json -{ - "longUrl": "https://www.facebook.com/something/something", - "shortUrl": "https://doma.in/rY9Kr", - "shortCode": "rY9Kr" -} -``` - -**ERROR RESPONSE** - -```json -{ - "error": "INVALID_URL", - "message": "Provided URL \"wfwef\" is invalid. Try with a different one." -} -``` - -Posible errors: - -* **INVALID_ARGUMENT**: The longUrl was not provided. -* **INVALID_URL**: Provided longUrl has an invalid format or does not resolve. -* **UNKNOWN_ERROR**: Something unexpected happened. - - -#### Resolve URL - -**REQUEST** - -* `GET` -> `/rest/short-codes/{shortCode}` -* Route params: - * shortCode: `string` -> The short code we want to resolve -* Headers: - * X-Auth-Token: `string` -> The token provided in the authentication request - -**SUCCESS RESPONSE** - -```json -{ - "longUrl": "https://www.facebook.com/something/something" -} -``` - -**ERROR RESPONSE** - -```json -{ - "error": "INVALID_SHORTCODE", - "message": "Provided short code \"abc123\" has an invalid format" -} -``` - -Posible errors: - -* **INVALID_ARGUMENT**: No longUrl was found for provided shortCode. -* **INVALID_SHORTCODE**: Provided shortCode does not match the character set used by the app to generate short codes. -* **UNKNOWN_ERROR**: Something unexpected happened. - - -#### List shortened URLs - -**REQUEST** - -* `GET` -> `/rest/short-codes` -* Query params: - * page: `integer` -> The page to list. Defaults to 1 if not provided. -* Headers: - * X-Auth-Token: `string` -> The token provided in the authentication request - -**SUCCESS RESPONSE** - -```json -{ - "shortUrls": { - "data": [ - { - "shortCode": "abc123", - "originalUrl": "http://www.alejandrocelaya.com", - "dateCreated": "2016-04-30T18:01:47+0200", - "visitsCount": 4 - }, - { - "shortCode": "def456", - "originalUrl": "http://www.alejandrocelaya.com/en", - "dateCreated": "2016-04-30T18:03:43+0200", - "visitsCount": 0 - }, - { - "shortCode": "ghi789", - "originalUrl": "http://www.alejandrocelaya.com/es", - "dateCreated": "2016-04-30T18:10:38+0200", - "visitsCount": 0 - }, - { - "shortCode": "jkl987", - "originalUrl": "http://www.alejandrocelaya.com/es/", - "dateCreated": "2016-04-30T18:10:57+0200", - "visitsCount": 0 - }, - { - "shortCode": "mno654", - "originalUrl": "http://blog.alejandrocelaya.com/2016/04/09/improving-zend-service-manager-workflow-with-annotations/", - "dateCreated": "2016-04-30T19:21:05+0200", - "visitsCount": 1 - }, - { - "shortCode": "pqr321", - "originalUrl": "http://www.google.com", - "dateCreated": "2016-05-01T11:19:53+0200", - "visitsCount": 0 - }, - { - "shortCode": "stv159", - "originalUrl": "http://www.acelaya.com", - "dateCreated": "2016-06-12T17:49:21+0200", - "visitsCount": 0 - }, - { - "shortCode": "wxy753", - "originalUrl": "http://www.atomic-reader.com", - "dateCreated": "2016-06-12T17:50:27+0200", - "visitsCount": 0 - }, - { - "shortCode": "zab852", - "originalUrl": "http://foo.com", - "dateCreated": "2016-07-03T09:07:36+0200", - "visitsCount": 0 - }, - { - "shortCode": "cde963", - "originalUrl": "https://www.facebook.com.com", - "dateCreated": "2016-07-03T09:12:35+0200", - "visitsCount": 0 - } - ], - "pagination": { - "currentPage": 4, - "pagesCount": 15 - } - } -} -``` - -**ERROR RESPONSE** - -```json -{ - "error": "UNKNOWN_ERROR", - "message": "Unexpected error occured" -} -``` - -Posible errors: - -* **UNKNOWN_ERROR**: Something unexpected happened. - - -#### Get visits - -**REQUEST** - -* `GET` -> `/rest/short-codes/{shortCode}/visits` -* Route params: - * shortCode: `string` -> The shortCode from which we eant to get the visits. -* Query params: - * startDate: `string` -> If provided, only visits older that this date will be returned - * endDate: `string` -> If provided, only visits newer that this date will be returned -* Headers: - * X-Auth-Token: `string` -> The token provided in the authentication request - -**SUCCESS RESPONSE** - -```json -{ - "shortUrls": { - "data": [ - { - "referer": null, - "date": "2016-06-18T09:32:22+0200", - "remoteAddr": "127.0.0.1", - "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36" - }, - { - "referer": null, - "date": "2016-04-30T19:20:06+0200", - "remoteAddr": "127.0.0.1", - "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36" - }, - { - "referer": "google.com", - "date": "2016-04-30T19:19:57+0200", - "remoteAddr": "1.2.3.4", - "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36" - }, - { - "referer": null, - "date": "2016-04-30T19:17:35+0200", - "remoteAddr": "127.0.0.1", - "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36" - } - ], - } -} -``` - -**ERROR RESPONSE** - -```json -{ - "error": "INVALID_ARGUMENT", - "message": "Provided short code \"abc123\" is invalid" -} -``` - -Posible errors: - -* **INVALID_ARGUMENT**: The shortcode does not belong to any short URL -* **UNKNOWN_ERROR**: Something unexpected happened. diff --git a/data/log/.gitignore b/data/log/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/data/log/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index a9b13a72..1244e259 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -5,12 +5,16 @@ return [ 'cli' => [ 'commands' => [ - Command\GenerateShortcodeCommand::class, - Command\ResolveUrlCommand::class, - Command\ListShortcodesCommand::class, - Command\GetVisitsCommand::class, - Command\ProcessVisitsCommand::class, + Command\Shortcode\GenerateShortcodeCommand::class, + Command\Shortcode\ResolveUrlCommand::class, + Command\Shortcode\ListShortcodesCommand::class, + Command\Shortcode\GetVisitsCommand::class, + Command\Visit\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..ebc607c8 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -10,13 +10,16 @@ return [ 'factories' => [ Application::class => ApplicationFactory::class, - Command\GenerateShortcodeCommand::class => AnnotatedFactory::class, - Command\ResolveUrlCommand::class => AnnotatedFactory::class, - Command\ListShortcodesCommand::class => AnnotatedFactory::class, - Command\GetVisitsCommand::class => AnnotatedFactory::class, - Command\ProcessVisitsCommand::class => AnnotatedFactory::class, - Command\ProcessVisitsCommand::class => AnnotatedFactory::class, + Command\Shortcode\GenerateShortcodeCommand::class => AnnotatedFactory::class, + Command\Shortcode\ResolveUrlCommand::class => AnnotatedFactory::class, + Command\Shortcode\ListShortcodesCommand::class => AnnotatedFactory::class, + Command\Shortcode\GetVisitsCommand::class => AnnotatedFactory::class, + Command\Visit\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/src/Command/GenerateShortcodeCommand.php b/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php similarity index 97% rename from module/CLI/src/Command/GenerateShortcodeCommand.php rename to module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php index f02c110b..2830fb40 100644 --- a/module/CLI/src/Command/GenerateShortcodeCommand.php +++ b/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php @@ -1,5 +1,5 @@ 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/CLI/test/Command/GenerateShortcodeCommandTest.php b/module/CLI/test/Command/GenerateShortcodeCommandTest.php index 45cb8130..011dcb32 100644 --- a/module/CLI/test/Command/GenerateShortcodeCommandTest.php +++ b/module/CLI/test/Command/GenerateShortcodeCommandTest.php @@ -4,7 +4,7 @@ namespace ShlinkioTest\Shlink\CLI\Command; use PHPUnit_Framework_TestCase as TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Command\GenerateShortcodeCommand; +use Shlinkio\Shlink\CLI\Command\Shortcode\GenerateShortcodeCommand; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Symfony\Component\Console\Application; diff --git a/module/CLI/test/Command/GetVisitsCommandTest.php b/module/CLI/test/Command/GetVisitsCommandTest.php index 4294823b..5657a91c 100644 --- a/module/CLI/test/Command/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/GetVisitsCommandTest.php @@ -4,7 +4,7 @@ namespace ShlinkioTest\Shlink\CLI\Command; use PHPUnit_Framework_TestCase as TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Command\GetVisitsCommand; +use Shlinkio\Shlink\CLI\Command\Shortcode\GetVisitsCommand; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; diff --git a/module/CLI/test/Command/ListShortcodesCommandTest.php b/module/CLI/test/Command/ListShortcodesCommandTest.php index 7ec0e5af..1a1884ec 100644 --- a/module/CLI/test/Command/ListShortcodesCommandTest.php +++ b/module/CLI/test/Command/ListShortcodesCommandTest.php @@ -4,7 +4,7 @@ namespace ShlinkioTest\Shlink\CLI\Command; use PHPUnit_Framework_TestCase as TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Command\ListShortcodesCommand; +use Shlinkio\Shlink\CLI\Command\Shortcode\ListShortcodesCommand; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Symfony\Component\Console\Application; diff --git a/module/CLI/test/Command/ProcessVisitsCommandTest.php b/module/CLI/test/Command/ProcessVisitsCommandTest.php index 23463123..900592e5 100644 --- a/module/CLI/test/Command/ProcessVisitsCommandTest.php +++ b/module/CLI/test/Command/ProcessVisitsCommandTest.php @@ -4,7 +4,7 @@ namespace ShlinkioTest\Shlink\CLI\Command; use PHPUnit_Framework_TestCase as TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Command\ProcessVisitsCommand; +use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand; use Shlinkio\Shlink\Common\Service\IpLocationResolver; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Service\VisitService; diff --git a/module/CLI/test/Command/ResolveUrlCommandTest.php b/module/CLI/test/Command/ResolveUrlCommandTest.php index a2f47c92..06b5d350 100644 --- a/module/CLI/test/Command/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ResolveUrlCommandTest.php @@ -3,7 +3,7 @@ namespace ShlinkioTest\Shlink\CLI\Command; use PHPUnit_Framework_TestCase as TestCase; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Command\ResolveUrlCommand; +use Shlinkio\Shlink\CLI\Command\Shortcode\ResolveUrlCommand; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Symfony\Component\Console\Application; diff --git a/module/Common/config/dependencies.config.php b/module/Common/config/dependencies.config.php index bcd30faa..b0cd571a 100644 --- a/module/Common/config/dependencies.config.php +++ b/module/Common/config/dependencies.config.php @@ -2,9 +2,12 @@ use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory; use Doctrine\Common\Cache\Cache; use Doctrine\ORM\EntityManager; +use Monolog\Logger; +use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\ErrorHandler; use Shlinkio\Shlink\Common\Factory\CacheFactory; use Shlinkio\Shlink\Common\Factory\EntityManagerFactory; +use Shlinkio\Shlink\Common\Factory\LoggerFactory; use Shlinkio\Shlink\Common\Factory\TranslatorFactory; use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware; use Shlinkio\Shlink\Common\Service\IpLocationResolver; @@ -19,11 +22,15 @@ return [ EntityManager::class => EntityManagerFactory::class, GuzzleHttp\Client::class => InvokableFactory::class, Cache::class => CacheFactory::class, - IpLocationResolver::class => AnnotatedFactory::class, + LoggerInterface::class => LoggerFactory::class, + 'Logger_Shlink' => LoggerFactory::class, + Translator::class => TranslatorFactory::class, TranslatorExtension::class => AnnotatedFactory::class, LocaleMiddleware::class => AnnotatedFactory::class, + IpLocationResolver::class => AnnotatedFactory::class, + ErrorHandler\ContentBasedErrorHandler::class => AnnotatedFactory::class, ErrorHandler\ErrorHandlerManager::class => ErrorHandler\ErrorHandlerManagerFactory::class, ], @@ -31,6 +38,8 @@ return [ 'em' => EntityManager::class, 'httpClient' => GuzzleHttp\Client::class, 'translator' => Translator::class, + 'logger' => LoggerInterface::class, + Logger::class => LoggerInterface::class, AnnotatedFactory::CACHE_SERVICE => Cache::class, ], ], diff --git a/module/Common/src/Factory/CacheFactory.php b/module/Common/src/Factory/CacheFactory.php index c866b980..905ebf56 100644 --- a/module/Common/src/Factory/CacheFactory.php +++ b/module/Common/src/Factory/CacheFactory.php @@ -1,8 +1,7 @@ resolveCacheAdapter($config['cache']); } // If the adapter has not been set in config, create one based on environment - return env('APP_ENV', 'pro') === 'pro' ? new ApcuCache() : new ArrayCache(); + return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache(); + } + + /** + * @param array $cacheConfig + * @return Cache\Cache + */ + protected function resolveCacheAdapter(array $cacheConfig) + { + switch ($cacheConfig['adapter']) { + case Cache\ArrayCache::class: + case Cache\ApcuCache::class: + return new $cacheConfig['adapter'](); + case Cache\FilesystemCache::class: + case Cache\PhpFileCache::class: + return new $cacheConfig['adapter']($cacheConfig['options']['dir']); + case Cache\MemcachedCache::class: + $memcached = new \Memcached(); + $servers = isset($cacheConfig['options']['servers']) ? $cacheConfig['options']['servers'] : []; + + foreach ($servers as $server) { + if (! isset($server['host'])) { + continue; + } + $port = isset($server['port']) ? intval($server['port']) : 11211; + + $memcached->addServer($server['host'], $port); + } + + $cache = new Cache\MemcachedCache(); + $cache->setMemcached($memcached); + return $cache; + default: + return new Cache\ArrayCache(); + } } } 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/src/Factory/LoggerFactory.php b/module/Common/src/Factory/LoggerFactory.php new file mode 100644 index 00000000..d42b2f01 --- /dev/null +++ b/module/Common/src/Factory/LoggerFactory.php @@ -0,0 +1,39 @@ +has('config') ? $container->get('config') : []; + Cascade::fileConfig(isset($config['logger']) ? $config['logger'] : ['loggers' => []]); + + // Compose requested logger name + $loggerName = isset($options) & isset($options['logger_name']) ? $options['logger_name'] : 'Logger'; + $nameParts = explode('_', $requestedName); + if (count($nameParts) > 1) { + $loggerName = $nameParts[1]; + } + + return Cascade::getLogger($loggerName); + } +} diff --git a/module/Common/src/Response/QrCodeResponse.php b/module/Common/src/Response/QrCodeResponse.php new file mode 100644 index 00000000..e19e4578 --- /dev/null +++ b/module/Common/src/Response/QrCodeResponse.php @@ -0,0 +1,35 @@ +createBody($qrCode), + $status, + $this->injectContentType($qrCode->getContentType(), $headers) + ); + } + + /** + * Create the message body. + * + * @param QrCode $qrCode + * @return StreamInterface + */ + private function createBody(QrCode $qrCode) + { + $body = new Stream('php://temp', 'wb+'); + $body->write($qrCode->get()); + $body->rewind(); + return $body; + } +} diff --git a/module/Common/test/Factory/CacheFactoryTest.php b/module/Common/test/Factory/CacheFactoryTest.php index 2e938dfa..b7fc49a8 100644 --- a/module/Common/test/Factory/CacheFactoryTest.php +++ b/module/Common/test/Factory/CacheFactoryTest.php @@ -4,6 +4,8 @@ namespace ShlinkioTest\Shlink\Common\Factory; use Doctrine\Common\Cache\ApcuCache; use Doctrine\Common\Cache\ArrayCache; use Doctrine\Common\Cache\FilesystemCache; +use Doctrine\Common\Cache\MemcachedCache; +use Doctrine\Common\Cache\RedisCache; use PHPUnit_Framework_TestCase as TestCase; use Shlinkio\Shlink\Common\Factory\CacheFactory; use Zend\ServiceManager\ServiceManager; @@ -61,15 +63,51 @@ class CacheFactoryTest extends TestCase public function invalidAdapterDefinedInConfigFallbacksToEnvironment() { putenv('APP_ENV=pro'); - $instance = $this->factory->__invoke($this->createSM(FilesystemCache::class), ''); + $instance = $this->factory->__invoke($this->createSM(RedisCache::class), ''); $this->assertInstanceOf(ApcuCache::class, $instance); } - private function createSM($cacheAdapter = null) + /** + * @test + */ + public function filesystemCacheAdaptersReadDirOption() + { + $dir = sys_get_temp_dir(); + /** @var FilesystemCache $instance */ + $instance = $this->factory->__invoke($this->createSM(FilesystemCache::class, ['dir' => $dir]), ''); + $this->assertInstanceOf(FilesystemCache::class, $instance); + $this->assertEquals($dir, $instance->getDirectory()); + } + + /** + * @test + */ + public function memcachedCacheAdaptersReadServersOption() + { + $servers = [ + [ + 'host' => '1.2.3.4', + 'port' => 123 + ], + [ + 'host' => '4.3.2.1', + 'port' => 321 + ], + ]; + /** @var MemcachedCache $instance */ + $instance = $this->factory->__invoke($this->createSM(MemcachedCache::class, ['servers' => $servers]), ''); + $this->assertInstanceOf(MemcachedCache::class, $instance); + $this->assertEquals(count($servers), count($instance->getMemcached()->getServerList())); + } + + private function createSM($cacheAdapter = null, array $options = []) { return new ServiceManager(['services' => [ 'config' => isset($cacheAdapter) ? [ - 'cache' => ['adapter' => $cacheAdapter], + 'cache' => [ + 'adapter' => $cacheAdapter, + 'options' => $options, + ], ] : [], ]]); } 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/Common/test/Factory/LoggerFactoryTest.php b/module/Common/test/Factory/LoggerFactoryTest.php new file mode 100644 index 00000000..bf292a1f --- /dev/null +++ b/module/Common/test/Factory/LoggerFactoryTest.php @@ -0,0 +1,54 @@ +factory = new LoggerFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + /** @var Logger $instance */ + $instance = $this->factory->__invoke(new ServiceManager(), ''); + $this->assertInstanceOf(LoggerInterface::class, $instance); + $this->assertEquals('Logger', $instance->getName()); + } + + /** + * @test + */ + public function nameIsSetFromOptions() + { + /** @var Logger $instance */ + $instance = $this->factory->__invoke(new ServiceManager(), '', ['logger_name' => 'Foo']); + $this->assertInstanceOf(LoggerInterface::class, $instance); + $this->assertEquals('Foo', $instance->getName()); + } + + /** + * @test + */ + public function serviceNameOverwritesOptionsLoggerName() + { + /** @var Logger $instance */ + $instance = $this->factory->__invoke(new ServiceManager(), 'Logger_Shlink', ['logger_name' => 'Foo']); + $this->assertInstanceOf(LoggerInterface::class, $instance); + $this->assertEquals('Shlink', $instance->getName()); + } +} 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..6a5596ee 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -1,12 +1,16 @@ [ 'factories' => [ + AppOptions::class => AnnotatedFactory::class, + // Services Service\UrlShortener::class => AnnotatedFactory::class, Service\VisitsTracker::class => AnnotatedFactory::class, @@ -14,7 +18,9 @@ return [ Service\VisitService::class => AnnotatedFactory::class, // Middleware - RedirectAction::class => AnnotatedFactory::class, + Action\RedirectAction::class => AnnotatedFactory::class, + Action\QrCodeAction::class => AnnotatedFactory::class, + Middleware\QrCodeCacheMiddleware::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/config/routes.config.php b/module/Core/config/routes.config.php index 4d9a85e5..9b0c071f 100644 --- a/module/Core/config/routes.config.php +++ b/module/Core/config/routes.config.php @@ -1,5 +1,6 @@ 'long-url-redirect', 'path' => '/{shortCode}', - 'middleware' => RedirectAction::class, + 'middleware' => Action\RedirectAction::class, + 'allowed_methods' => ['GET'], + ], + [ + 'name' => 'short-url-qr-code', + 'path' => '/qr/{shortCode}[/{size:[0-9]+}]', + 'middleware' => [ + Middleware\QrCodeCacheMiddleware::class, + Action\QrCodeAction::class, + ], 'allowed_methods' => ['GET'], ], ], diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php new file mode 100644 index 00000000..5d0549eb --- /dev/null +++ b/module/Core/src/Action/QrCodeAction.php @@ -0,0 +1,113 @@ +router = $router; + $this->urlShortener = $urlShortener; + $this->logger = $logger ?: new NullLogger(); + } + + /** + * Process an incoming request and/or response. + * + * Accepts a server-side request and a response instance, and does + * something with them. + * + * If the response is not complete and/or further processing would not + * interfere with the work done in the middleware, or if the middleware + * wants to delegate to another process, it can use the `$out` callable + * if present. + * + * If the middleware does not return a value, execution of the current + * request is considered complete, and the response instance provided will + * be considered the response to return. + * + * Alternately, the middleware may return a response instance. + * + * Often, middleware will `return $out();`, with the assumption that a + * later middleware will return a response. + * + * @param Request $request + * @param Response $response + * @param null|callable $out + * @return null|Response + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + // Make sure the short URL exists for this short code + $shortCode = $request->getAttribute('shortCode'); + try { + $shortUrl = $this->urlShortener->shortCodeToUrl($shortCode); + if (! isset($shortUrl)) { + return $out($request, $response->withStatus(404), 'Not Found'); + } + } catch (InvalidShortCodeException $e) { + $this->logger->warning('Tried to create a QR code with an invalid short code' . PHP_EOL . $e); + return $out($request, $response->withStatus(404), 'Not Found'); + } + + $path = $this->router->generateUri('long-url-redirect', ['shortCode' => $shortCode]); + $size = $this->getSizeParam($request); + + $qrCode = new QrCode($request->getUri()->withPath($path)->withQuery('')); + $qrCode->setSize($size) + ->setPadding(0); + return new QrCodeResponse($qrCode); + } + + /** + * @param Request $request + * @return int + */ + protected function getSizeParam(Request $request) + { + $size = intval($request->getAttribute('size', 300)); + if ($size < 50) { + return 50; + } elseif ($size > 1000) { + return 1000; + } + + return $size; + } +} diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 031aa0fe..a86a98c4 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -4,6 +4,8 @@ namespace Shlinkio\Shlink\Core\Action; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\VisitsTracker; @@ -18,21 +20,30 @@ class RedirectAction implements MiddlewareInterface */ private $urlShortener; /** - * @var VisitsTracker|VisitsTrackerInterface + * @var VisitsTrackerInterface */ private $visitTracker; + /** + * @var null|LoggerInterface + */ + private $logger; /** * RedirectMiddleware constructor. - * @param UrlShortenerInterface|UrlShortener $urlShortener - * @param VisitsTrackerInterface|VisitsTracker $visitTracker + * @param UrlShortenerInterface $urlShortener + * @param VisitsTrackerInterface $visitTracker + * @param LoggerInterface|null $logger * - * @Inject({UrlShortener::class, VisitsTracker::class}) + * @Inject({UrlShortener::class, VisitsTracker::class, "Logger_Shlink"}) */ - public function __construct(UrlShortenerInterface $urlShortener, VisitsTrackerInterface $visitTracker) - { + public function __construct( + UrlShortenerInterface $urlShortener, + VisitsTrackerInterface $visitTracker, + LoggerInterface $logger = null + ) { $this->urlShortener = $urlShortener; $this->visitTracker = $visitTracker; + $this->logger = $logger ?: new NullLogger(); } /** @@ -74,13 +85,14 @@ class RedirectAction implements MiddlewareInterface } // Track visit to this short code - $this->visitTracker->track($shortCode); + $this->visitTracker->track($shortCode, $request); // Return a redirect response to the long URL. // Use a temporary redirect to make sure browsers always hit the server for analytics purposes return new RedirectResponse($longUrl); } catch (\Exception $e) { // In case of error, dispatch 404 error + $this->logger->error('Error redirecting to long URL.' . PHP_EOL . $e); return $this->notFoundResponse($request, $response, $out); } } 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/Middleware/QrCodeCacheMiddleware.php b/module/Core/src/Middleware/QrCodeCacheMiddleware.php new file mode 100644 index 00000000..d0f132bb --- /dev/null +++ b/module/Core/src/Middleware/QrCodeCacheMiddleware.php @@ -0,0 +1,73 @@ +cache = $cache; + } + + /** + * Process an incoming request and/or response. + * + * Accepts a server-side request and a response instance, and does + * something with them. + * + * If the response is not complete and/or further processing would not + * interfere with the work done in the middleware, or if the middleware + * wants to delegate to another process, it can use the `$out` callable + * if present. + * + * If the middleware does not return a value, execution of the current + * request is considered complete, and the response instance provided will + * be considered the response to return. + * + * Alternately, the middleware may return a response instance. + * + * Often, middleware will `return $out();`, with the assumption that a + * later middleware will return a response. + * + * @param Request $request + * @param Response $response + * @param null|callable $out + * @return null|Response + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + $cacheKey = $request->getUri()->getPath(); + + // If this QR code is already cached, just return it + if ($this->cache->contains($cacheKey)) { + $qrData = $this->cache->fetch($cacheKey); + $response->getBody()->write($qrData['body']); + return $response->withHeader('Content-Type', $qrData['content-type']); + } + + // If not, call the next middleware and cache it + /** @var Response $resp */ + $resp = $out($request, $response); + $this->cache->save($cacheKey, [ + 'body' => $resp->getBody()->__toString(), + 'content-type' => $resp->getHeaderLine('Content-Type'), + ]); + return $resp; + } +} 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/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 01a8d4b6..ed87b604 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -2,6 +2,7 @@ namespace Shlinkio\Shlink\Core\Service; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Doctrine\Common\Cache\Cache; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ORMException; use GuzzleHttp\ClientInterface; @@ -28,23 +29,30 @@ class UrlShortener implements UrlShortenerInterface * @var string */ private $chars; + /** + * @var Cache + */ + private $cache; /** * UrlShortener constructor. * @param ClientInterface $httpClient * @param EntityManagerInterface $em + * @param Cache $cache * @param string $chars * - * @Inject({"httpClient", "em", "config.url_shortener.shortcode_chars"}) + * @Inject({"httpClient", "em", Cache::class, "config.url_shortener.shortcode_chars"}) */ public function __construct( ClientInterface $httpClient, EntityManagerInterface $em, + Cache $cache, $chars = self::DEFAULT_CHARS ) { $this->httpClient = $httpClient; $this->em = $em; $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars; + $this->cache = $cache; } /** @@ -91,7 +99,7 @@ class UrlShortener implements UrlShortenerInterface $this->em->close(); } - throw new RuntimeException('An error occured while persisting the short URL', -1, $e); + throw new RuntimeException('An error occurred while persisting the short URL', -1, $e); } } @@ -140,6 +148,12 @@ class UrlShortener implements UrlShortenerInterface */ public function shortCodeToUrl($shortCode) { + $cacheKey = sprintf('%s_longUrl', $shortCode); + // Check if the short code => URL map is already cached + if ($this->cache->contains($cacheKey)) { + return $this->cache->fetch($cacheKey); + } + // Validate short code format if (! preg_match('|[' . $this->chars . "]+|", $shortCode)) { throw InvalidShortCodeException::fromShortCode($shortCode, $this->chars); @@ -149,6 +163,13 @@ class UrlShortener implements UrlShortenerInterface $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ 'shortCode' => $shortCode, ]); - return isset($shortUrl) ? $shortUrl->getOriginalUrl() : null; + // Cache the shortcode + if (isset($shortUrl)) { + $url = $shortUrl->getOriginalUrl(); + $this->cache->save($cacheKey, $url); + return $url; + } + + return null; } } diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index 87187aad..94647086 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -3,6 +3,7 @@ namespace Shlinkio\Shlink\Core\Service; use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Doctrine\ORM\EntityManagerInterface; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -31,12 +32,10 @@ class VisitsTracker implements VisitsTrackerInterface * Tracks a new visit to provided short code, using an array of data to look up information * * @param string $shortCode - * @param array $visitorData Defaults to global $_SERVER + * @param ServerRequestInterface $request */ - public function track($shortCode, array $visitorData = null) + public function track($shortCode, ServerRequestInterface $request) { - $visitorData = $visitorData ?: $_SERVER; - /** @var ShortUrl $shortUrl */ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ 'shortCode' => $shortCode, @@ -44,22 +43,27 @@ class VisitsTracker implements VisitsTrackerInterface $visit = new Visit(); $visit->setShortUrl($shortUrl) - ->setUserAgent($this->getArrayValue($visitorData, 'HTTP_USER_AGENT')) - ->setReferer($this->getArrayValue($visitorData, 'HTTP_REFERER')) - ->setRemoteAddr($this->getArrayValue($visitorData, 'REMOTE_ADDR')); + ->setUserAgent($request->getHeaderLine('User-Agent')) + ->setReferer($request->getHeaderLine('Referer')) + ->setRemoteAddr($this->findOutRemoteAddr($request)); $this->em->persist($visit); $this->em->flush(); } /** - * @param array $array - * @param $key - * @param null $default - * @return mixed|null + * @param ServerRequestInterface $request + * @return string */ - protected function getArrayValue(array $array, $key, $default = null) + protected function findOutRemoteAddr(ServerRequestInterface $request) { - return isset($array[$key]) ? $array[$key] : $default; + $forwardedFor = $request->getHeaderLine('X-Forwarded-For'); + if (empty($forwardedFor)) { + $serverParams = $request->getServerParams(); + return isset($serverParams['REMOTE_ADDR']) ? $serverParams['REMOTE_ADDR'] : null; + } + + $ips = explode(',', $forwardedFor); + return $ips[0]; } /** diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index cec254d3..6aecabe4 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -1,6 +1,7 @@ {% block footer %}

- © {{ "now" | date("Y") }} by Alejandro Celaya. + © {{ "now" | date("Y") }} Shlink

{% endblock %} diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php new file mode 100644 index 00000000..d4924724 --- /dev/null +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -0,0 +1,93 @@ +prophesize(RouterInterface::class); + $router->generateUri(Argument::cetera())->willReturn('/foo/bar'); + + $this->urlShortener = $this->prophesize(UrlShortener::class); + + $this->action = new QrCodeAction($router->reveal(), $this->urlShortener->reveal()); + } + + /** + * @test + */ + public function aNonexistentShortCodeWillReturnNotFoundResponse() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null)->shouldBeCalledTimes(1); + + $resp = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response(), + function ($req, $resp) { + return $resp; + } + ); + $this->assertEquals(404, $resp->getStatusCode()); + } + + /** + * @test + */ + public function anInvalidShortCodeWillReturnNotFoundResponse() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class) + ->shouldBeCalledTimes(1); + + $resp = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response(), + function ($req, $resp) { + return $resp; + } + ); + $this->assertEquals(404, $resp->getStatusCode()); + } + + /** + * @test + */ + public function aCorrectRequestReturnsTheQrCodeResponse() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(new ShortUrl())->shouldBeCalledTimes(1); + + $resp = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response(), + function ($req, $resp) { + return $resp; + } + ); + + $this->assertInstanceOf(QrCodeResponse::class, $resp); + $this->assertEquals(200, $resp->getStatusCode()); + } +} diff --git a/module/Core/test/Middleware/QrCodeCacheMiddlewareTest.php b/module/Core/test/Middleware/QrCodeCacheMiddlewareTest.php new file mode 100644 index 00000000..64aac3e0 --- /dev/null +++ b/module/Core/test/Middleware/QrCodeCacheMiddlewareTest.php @@ -0,0 +1,69 @@ +cache = new ArrayCache(); + $this->middleware = new QrCodeCacheMiddleware($this->cache); + } + + /** + * @test + */ + public function noCachedPathFallbacksToNextMiddleware() + { + $isCalled = false; + $this->middleware->__invoke( + ServerRequestFactory::fromGlobals(), + new Response(), + function ($req, $resp) use (&$isCalled) { + $isCalled = true; + return $resp; + } + ); + $this->assertTrue($isCalled); + } + + /** + * @test + */ + public function cachedPathReturnsCacheContent() + { + $isCalled = false; + $uri = (new Uri())->withPath('/foo'); + $this->cache->save('/foo', ['body' => 'the body', 'content-type' => 'image/png']); + + $resp = $this->middleware->__invoke( + ServerRequestFactory::fromGlobals()->withUri($uri), + new Response(), + function ($req, $resp) use (&$isCalled) { + $isCalled = true; + return $resp; + } + ); + + $this->assertFalse($isCalled); + $resp->getBody()->rewind(); + $this->assertEquals('the body', $resp->getBody()->getContents()); + $this->assertEquals('image/png', $resp->getHeaderLine('Content-Type')); + } +} diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index 8298a8cc..4cf7da1d 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -1,6 +1,8 @@ findOneBy(Argument::any())->willReturn(null); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $this->urlShortener = new UrlShortener($this->httpClient->reveal(), $this->em->reveal()); + $this->cache = new ArrayCache(); + + $this->urlShortener = new UrlShortener($this->httpClient->reveal(), $this->em->reveal(), $this->cache); } /** @@ -112,16 +120,19 @@ class UrlShortenerTest extends TestCase public function shortCodeIsProperlyParsed() { // 12C1c -> 10 + $shortCode = '12C1c'; $shortUrl = new ShortUrl(); - $shortUrl->setShortCode('12C1c') + $shortUrl->setShortCode($shortCode) ->setOriginalUrl('expected_url'); $repo = $this->prophesize(ObjectRepository::class); - $repo->findOneBy(['shortCode' => '12C1c'])->willReturn($shortUrl); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $url = $this->urlShortener->shortCodeToUrl('12C1c'); + $this->assertFalse($this->cache->contains($shortCode . '_longUrl')); + $url = $this->urlShortener->shortCodeToUrl($shortCode); $this->assertEquals($shortUrl->getOriginalUrl(), $url); + $this->assertTrue($this->cache->contains($shortCode . '_longUrl')); } /** @@ -132,4 +143,18 @@ class UrlShortenerTest extends TestCase { $this->urlShortener->shortCodeToUrl('&/('); } + + /** + * @test + */ + public function cachedShortCodeDoesNotHitDatabase() + { + $shortCode = '12C1c'; + $expectedUrl = 'expected_url'; + $this->cache->save($shortCode . '_longUrl', $expectedUrl); + $this->em->getRepository(ShortUrl::class)->willReturn(null)->shouldBeCalledTimes(0); + + $url = $this->urlShortener->shortCodeToUrl($shortCode); + $this->assertEquals($expectedUrl, $url); + } } diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 4d8a68fe..557b715f 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Service\VisitsTracker; +use Zend\Diactoros\ServerRequestFactory; class VisitsTrackerTest extends TestCase { @@ -41,7 +42,30 @@ class VisitsTrackerTest extends TestCase $this->em->persist(Argument::any())->shouldBeCalledTimes(1); $this->em->flush()->shouldBeCalledTimes(1); - $this->visitsTracker->track($shortCode); + $this->visitsTracker->track($shortCode, ServerRequestFactory::fromGlobals()); + } + + /** + * @test + */ + public function trackUsesForwardedForHeaderIfPresent() + { + $shortCode = '123ABC'; + $test = $this; + $repo = $this->prophesize(EntityRepository::class); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl()); + + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); + $this->em->persist(Argument::any())->will(function ($args) use ($test) { + /** @var Visit $visit */ + $visit = $args[0]; + $test->assertEquals('4.3.2.1', $visit->getRemoteAddr()); + })->shouldBeCalledTimes(1); + $this->em->flush()->shouldBeCalledTimes(1); + + $this->visitsTracker->track($shortCode, ServerRequestFactory::fromGlobals( + ['REMOTE_ADDR' => '1.2.3.4'] + )->withHeader('X-Forwarded-For', '4.3.2.1,99.99.99.99')); } /** 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/AbstractRestAction.php b/module/Rest/src/Action/AbstractRestAction.php index 587e1a93..4acbe2ba 100644 --- a/module/Rest/src/Action/AbstractRestAction.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -3,10 +3,22 @@ namespace Shlinkio\Shlink\Rest\Action; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Zend\Stratigility\MiddlewareInterface; abstract class AbstractRestAction implements MiddlewareInterface { + /** + * @var LoggerInterface + */ + protected $logger; + + public function __construct(LoggerInterface $logger = null) + { + $this->logger = $logger ?: new NullLogger(); + } + /** * Process an incoming request and/or response. * 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/Action/CreateShortcodeAction.php b/module/Rest/src/Action/CreateShortcodeAction.php index 29aa1108..5dd1221e 100644 --- a/module/Rest/src/Action/CreateShortcodeAction.php +++ b/module/Rest/src/Action/CreateShortcodeAction.php @@ -4,6 +4,7 @@ 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 Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; @@ -33,14 +34,17 @@ class CreateShortcodeAction extends AbstractRestAction * @param UrlShortenerInterface|UrlShortener $urlShortener * @param TranslatorInterface $translator * @param array $domainConfig + * @param LoggerInterface|null $logger * - * @Inject({UrlShortener::class, "translator", "config.url_shortener.domain"}) + * @Inject({UrlShortener::class, "translator", "config.url_shortener.domain", "Logger_Shlink"}) */ public function __construct( UrlShortenerInterface $urlShortener, TranslatorInterface $translator, - array $domainConfig + array $domainConfig, + LoggerInterface $logger = null ) { + parent::__construct($logger); $this->urlShortener = $urlShortener; $this->translator = $translator; $this->domainConfig = $domainConfig; @@ -75,14 +79,16 @@ class CreateShortcodeAction extends AbstractRestAction 'shortCode' => $shortCode, ]); } catch (InvalidUrlException $e) { + $this->logger->warning('Provided Invalid URL.' . PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::getRestErrorCodeFromException($e), 'message' => sprintf( - $this->translator->translate('Provided URL "%s" is invalid. Try with a different one.'), + $this->translator->translate('Provided URL %s is invalid. Try with a different one.'), $longUrl ), ], 400); } catch (\Exception $e) { + $this->logger->error('Unexpected error creating shortcode.' . PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, 'message' => $this->translator->translate('Unexpected error occurred'), diff --git a/module/Rest/src/Action/GetVisitsAction.php b/module/Rest/src/Action/GetVisitsAction.php index bd78adbb..0d78bcc1 100644 --- a/module/Rest/src/Action/GetVisitsAction.php +++ b/module/Rest/src/Action/GetVisitsAction.php @@ -4,6 +4,7 @@ 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 Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Service\VisitsTracker; @@ -25,13 +26,18 @@ class GetVisitsAction extends AbstractRestAction /** * GetVisitsAction constructor. - * @param VisitsTrackerInterface|VisitsTracker $visitsTracker + * @param VisitsTrackerInterface $visitsTracker * @param TranslatorInterface $translator + * @param LoggerInterface $logger * - * @Inject({VisitsTracker::class, "translator"}) + * @Inject({VisitsTracker::class, "translator", "Logger_Shlink"}) */ - public function __construct(VisitsTrackerInterface $visitsTracker, TranslatorInterface $translator) - { + public function __construct( + VisitsTrackerInterface $visitsTracker, + TranslatorInterface $translator, + LoggerInterface $logger = null + ) { + parent::__construct($logger); $this->visitsTracker = $visitsTracker; $this->translator = $translator; } @@ -57,11 +63,16 @@ class GetVisitsAction extends AbstractRestAction ] ]); } catch (InvalidArgumentException $e) { + $this->logger->warning('Provided nonexistent shortcode'. PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf($this->translator->translate('Provided short code "%s" is invalid'), $shortCode), - ], 400); + 'message' => sprintf( + $this->translator->translate('Provided short code %s does not exist'), + $shortCode + ), + ], 404); } catch (\Exception $e) { + $this->logger->error('Unexpected error while parsing short code'. PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, 'message' => $this->translator->translate('Unexpected error occurred'), diff --git a/module/Rest/src/Action/ListShortcodesAction.php b/module/Rest/src/Action/ListShortcodesAction.php index 3d0d9613..1cd376bc 100644 --- a/module/Rest/src/Action/ListShortcodesAction.php +++ b/module/Rest/src/Action/ListShortcodesAction.php @@ -4,6 +4,8 @@ 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 Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -28,11 +30,16 @@ class ListShortcodesAction extends AbstractRestAction * ListShortcodesAction constructor. * @param ShortUrlServiceInterface|ShortUrlService $shortUrlService * @param TranslatorInterface $translator + * @param LoggerInterface $logger * - * @Inject({ShortUrlService::class, "translator"}) + * @Inject({ShortUrlService::class, "translator", "Logger_Shlink"}) */ - public function __construct(ShortUrlServiceInterface $shortUrlService, TranslatorInterface $translator) - { + public function __construct( + ShortUrlServiceInterface $shortUrlService, + TranslatorInterface $translator, + LoggerInterface $logger = null + ) { + parent::__construct($logger); $this->shortUrlService = $shortUrlService; $this->translator = $translator; } @@ -50,6 +57,7 @@ class ListShortcodesAction extends AbstractRestAction $shortUrls = $this->shortUrlService->listShortUrls(isset($query['page']) ? $query['page'] : 1); return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls)]); } catch (\Exception $e) { + $this->logger->error('Unexpected error while listing short URLs.' . PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, 'message' => $this->translator->translate('Unexpected error occurred'), diff --git a/module/Rest/src/Action/ResolveUrlAction.php b/module/Rest/src/Action/ResolveUrlAction.php index c1783834..c99e233a 100644 --- a/module/Rest/src/Action/ResolveUrlAction.php +++ b/module/Rest/src/Action/ResolveUrlAction.php @@ -4,6 +4,7 @@ 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 Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; @@ -26,11 +27,16 @@ class ResolveUrlAction extends AbstractRestAction * ResolveUrlAction constructor. * @param UrlShortenerInterface|UrlShortener $urlShortener * @param TranslatorInterface $translator + * @param LoggerInterface $logger * * @Inject({UrlShortener::class, "translator"}) */ - public function __construct(UrlShortenerInterface $urlShortener, TranslatorInterface $translator) - { + public function __construct( + UrlShortenerInterface $urlShortener, + TranslatorInterface $translator, + LoggerInterface $logger = null + ) { + parent::__construct($logger); $this->urlShortener = $urlShortener; $this->translator = $translator; } @@ -50,14 +56,15 @@ class ResolveUrlAction extends AbstractRestAction if (! isset($longUrl)) { return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, - 'message' => sprintf($this->translator->translate('No URL found for shortcode "%s"'), $shortCode), - ], 400); + 'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode), + ], 404); } return new JsonResponse([ 'longUrl' => $longUrl, ]); } catch (InvalidShortCodeException $e) { + $this->logger->warning('Provided short code with invalid format.' . PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::getRestErrorCodeFromException($e), 'message' => sprintf( @@ -66,6 +73,7 @@ class ResolveUrlAction extends AbstractRestAction ), ], 400); } catch (\Exception $e) { + $this->logger->error('Unexpected error while resolving the URL behind a short code.' . PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, 'message' => $this->translator->translate('Unexpected error occurred'), 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..53f6cbe9 100644 --- a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php @@ -4,9 +4,11 @@ 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 Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +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 +17,37 @@ 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; + /** + * @var LoggerInterface + */ + private $logger; /** * CheckAuthenticationMiddleware constructor. - * @param RestTokenServiceInterface|RestTokenService $restTokenService + * @param JWTServiceInterface|JWTService $jwtService * @param TranslatorInterface $translator + * @param LoggerInterface $logger * - * @Inject({RestTokenService::class, "translator"}) + * @Inject({JWTService::class, "translator", "Logger_Shlink"}) */ - public function __construct(RestTokenServiceInterface $restTokenService, TranslatorInterface $translator) - { - $this->restTokenService = $restTokenService; + public function __construct( + JWTServiceInterface $jwtService, + TranslatorInterface $translator, + LoggerInterface $logger = null + ) { $this->translator = $translator; + $this->jwtService = $jwtService; + $this->logger = $logger ?: new NullLogger(); } /** @@ -78,21 +89,47 @@ 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) { + $this->logger->warning('Tried to access API with an invalid JWT.' . PHP_EOL . $e); return $this->createTokenErrorResponse(); } } @@ -106,7 +143,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/Action/GetVisitsActionTest.php b/module/Rest/test/Action/GetVisitsActionTest.php index 901c549e..7e222269 100644 --- a/module/Rest/test/Action/GetVisitsActionTest.php +++ b/module/Rest/test/Action/GetVisitsActionTest.php @@ -59,7 +59,7 @@ class GetVisitsActionTest extends TestCase ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), new Response() ); - $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals(404, $response->getStatusCode()); } /** diff --git a/module/Rest/test/Action/ResolveUrlActionTest.php b/module/Rest/test/Action/ResolveUrlActionTest.php index 0bd3bded..07db655a 100644 --- a/module/Rest/test/Action/ResolveUrlActionTest.php +++ b/module/Rest/test/Action/ResolveUrlActionTest.php @@ -39,7 +39,7 @@ class ResolveUrlActionTest extends TestCase $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); $response = $this->action->__invoke($request, new Response()); - $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals(404, $response->getStatusCode()); $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_ARGUMENT_ERROR) > 0); } 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()); - } -}