From cbfdcff2615e901bdc434d06f38a3da8eecbdf8b Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 31 Jul 2016 10:46:17 +0200 Subject: Prepare settings for the API in the admin page and during the install API settings: - api.enabled - api.secret The API settings will be initialized (and the secret generated) with an update method. --- application/Updater.php | 23 +++++++++++++++++++++++ application/Utils.php | 26 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) (limited to 'application') diff --git a/application/Updater.php b/application/Updater.php index f0d02814..38de3350 100644 --- a/application/Updater.php +++ b/application/Updater.php @@ -256,6 +256,29 @@ class Updater return true; } + + /** + * Initialize API settings: + * - api.enabled: true + * - api.secret: generated secret + */ + public function updateMethodApiSettings() + { + if ($this->conf->exists('api.secret')) { + return true; + } + + $this->conf->set('api.enabled', true); + $this->conf->set( + 'api.secret', + generate_api_secret( + $this->conf->get('credentials.login'), + $this->conf->get('credentials.salt') + ) + ); + $this->conf->write($this->isLoggedIn); + return true; + } } /** diff --git a/application/Utils.php b/application/Utils.php index 0a5b476e..62902341 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -231,3 +231,29 @@ function autoLocale($headerLocale) } setlocale(LC_ALL, $attempts); } + +/** + * Generates a default API secret. + * + * Note that the random-ish methods used in this function are predictable, + * which makes them NOT suitable for crypto. + * BUT the random string is salted with the salt and hashed with the username. + * It makes the generated API secret secured enough for Shaarli. + * + * PHP 7 provides random_int(), designed for cryptography. + * More info: http://stackoverflow.com/questions/4356289/php-random-string-generator + + * @param string $username Shaarli login username + * @param string $salt Shaarli password hash salt + * + * @return string|bool Generated API secret, 12 char length. + * Or false if invalid parameters are provided (which will make the API unusable). + */ +function generate_api_secret($username, $salt) +{ + if (empty($username) || empty($salt)) { + return false; + } + + return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12)); +} -- cgit v1.2.3 From 18e6796726d73d7dc90ecdd16c181493941f5487 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 15 Dec 2016 10:13:00 +0100 Subject: REST API structure using Slim framework * REST API routes are handle by Slim. * Every API controller go through ApiMiddleware which handles security. * First service implemented `/info`, for tests purpose. --- application/api/ApiMiddleware.php | 132 +++++++++++++++++++++ application/api/ApiUtils.php | 51 ++++++++ application/api/controllers/ApiController.php | 54 +++++++++ application/api/controllers/Info.php | 42 +++++++ .../api/exceptions/ApiAuthorizationException.php | 34 ++++++ .../api/exceptions/ApiBadParametersException.php | 19 +++ application/api/exceptions/ApiException.php | 77 ++++++++++++ .../api/exceptions/ApiInternalException.php | 19 +++ application/config/ConfigManager.php | 4 +- 9 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 application/api/ApiMiddleware.php create mode 100644 application/api/ApiUtils.php create mode 100644 application/api/controllers/ApiController.php create mode 100644 application/api/controllers/Info.php create mode 100644 application/api/exceptions/ApiAuthorizationException.php create mode 100644 application/api/exceptions/ApiBadParametersException.php create mode 100644 application/api/exceptions/ApiException.php create mode 100644 application/api/exceptions/ApiInternalException.php (limited to 'application') diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php new file mode 100644 index 00000000..162e88e0 --- /dev/null +++ b/application/api/ApiMiddleware.php @@ -0,0 +1,132 @@ +container = $container; + $this->conf = $this->container->get('conf'); + $this->setLinkDb($this->conf); + } + + /** + * Middleware execution: + * - check the API request + * - execute the controller + * - return the response + * + * @param Request $request Slim request + * @param Response $response Slim response + * @param callable $next Next action + * + * @return Response response. + */ + public function __invoke($request, $response, $next) + { + try { + $this->checkRequest($request); + $response = $next($request, $response); + } catch(ApiException $e) { + $e->setResponse($response); + $e->setDebug($this->conf->get('dev.debug', false)); + $response = $e->getApiResponse(); + } + + return $response; + } + + /** + * Check the request validity (HTTP method, request value, etc.), + * that the API is enabled, and the JWT token validity. + * + * @param Request $request Slim request + * + * @throws ApiAuthorizationException The API is disabled or the token is invalid. + */ + protected function checkRequest($request) + { + if (! $this->conf->get('api.enabled', true)) { + throw new ApiAuthorizationException('API is disabled'); + } + $this->checkToken($request); + } + + /** + * Check that the JWT token is set and valid. + * The API secret setting must be set. + * + * @param Request $request Slim request + * + * @throws ApiAuthorizationException The token couldn't be validated. + */ + protected function checkToken($request) { + $jwt = $request->getHeaderLine('jwt'); + if (empty($jwt)) { + throw new ApiAuthorizationException('JWT token not provided'); + } + + if (empty($this->conf->get('api.secret'))) { + throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); + } + + ApiUtils::validateJwtToken($jwt, $this->conf->get('api.secret')); + } + + /** + * Instantiate a new LinkDB including private links, + * and load in the Slim container. + * + * FIXME! LinkDB could use a refactoring to avoid this trick. + * + * @param \ConfigManager $conf instance. + */ + protected function setLinkDb($conf) + { + $linkDb = new \LinkDB( + $conf->get('resource.datastore'), + true, + $conf->get('privacy.hide_public_links'), + $conf->get('redirector.url'), + $conf->get('redirector.encode_url') + ); + $this->container['db'] = $linkDb; + } +} diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php new file mode 100644 index 00000000..fbb1e72f --- /dev/null +++ b/application/api/ApiUtils.php @@ -0,0 +1,51 @@ +iat) + || $payload->iat > time() + || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION + ) { + throw new ApiAuthorizationException('Invalid JWT issued time'); + } + } +} diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php new file mode 100644 index 00000000..1dd47f17 --- /dev/null +++ b/application/api/controllers/ApiController.php @@ -0,0 +1,54 @@ +ci = $ci; + $this->conf = $ci->get('conf'); + $this->linkDb = $ci->get('db'); + if ($this->conf->get('dev.debug', false)) { + $this->jsonStyle = JSON_PRETTY_PRINT; + } else { + $this->jsonStyle = null; + } + } +} diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php new file mode 100644 index 00000000..25433f72 --- /dev/null +++ b/application/api/controllers/Info.php @@ -0,0 +1,42 @@ + count($this->linkDb), + 'private_counter' => count_private($this->linkDb), + 'settings' => array( + 'title' => $this->conf->get('general.title', 'Shaarli'), + 'header_link' => $this->conf->get('general.header_link', '?'), + 'timezone' => $this->conf->get('general.timezone', 'UTC'), + 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []), + 'default_private_links' => $this->conf->get('privacy.default_private_links', false), + ), + ]; + + return $response->withJson($info, 200, $this->jsonStyle); + } +} diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php new file mode 100644 index 00000000..0e3f4776 --- /dev/null +++ b/application/api/exceptions/ApiAuthorizationException.php @@ -0,0 +1,34 @@ +setMessage('Not authorized'); + return $this->buildApiResponse(401); + } + + /** + * Set the exception message. + * + * We only return a generic error message in production mode to avoid giving + * to much security information. + * + * @param $message string the exception message. + */ + public function setMessage($message) + { + $original = $this->debug === true ? ': '. $this->getMessage() : ''; + $this->message = $message . $original; + } +} diff --git a/application/api/exceptions/ApiBadParametersException.php b/application/api/exceptions/ApiBadParametersException.php new file mode 100644 index 00000000..e5cc19ea --- /dev/null +++ b/application/api/exceptions/ApiBadParametersException.php @@ -0,0 +1,19 @@ +buildApiResponse(400); + } +} diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php new file mode 100644 index 00000000..c8490e0c --- /dev/null +++ b/application/api/exceptions/ApiException.php @@ -0,0 +1,77 @@ +debug !== true) { + return $this->getMessage(); + } + return [ + 'message' => $this->getMessage(), + 'stacktrace' => get_class($this) .': '. $this->getTraceAsString() + ]; + } + + /** + * Build the Response object to return. + * + * @param int $code HTTP status. + * + * @return Response with status + body. + */ + protected function buildApiResponse($code) + { + $style = $this->debug ? JSON_PRETTY_PRINT : null; + return $this->response->withJson($this->getApiResponseBody(), $code, $style); + } + + /** + * @param Response $response + */ + public function setResponse($response) + { + $this->response = $response; + } + + /** + * @param bool $debug + */ + public function setDebug($debug) + { + $this->debug = $debug; + } +} diff --git a/application/api/exceptions/ApiInternalException.php b/application/api/exceptions/ApiInternalException.php new file mode 100644 index 00000000..1cb05532 --- /dev/null +++ b/application/api/exceptions/ApiInternalException.php @@ -0,0 +1,19 @@ +buildApiResponse(500); + } +} diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php index f5f753f8..ca8918b5 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -20,6 +20,8 @@ class ConfigManager */ protected static $NOT_FOUND = 'NOT_FOUND'; + public static $DEFAULT_PLUGINS = array('qrcode'); + /** * @var string Config folder. */ @@ -308,7 +310,7 @@ class ConfigManager $this->setEmpty('general.header_link', '?'); $this->setEmpty('general.links_per_page', 20); - $this->setEmpty('general.enabled_plugins', array('qrcode')); + $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); $this->setEmpty('updates.check_updates', false); $this->setEmpty('updates.check_updates_branch', 'stable'); -- cgit v1.2.3