aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/Updater.php23
-rw-r--r--application/Utils.php26
-rw-r--r--application/api/ApiMiddleware.php132
-rw-r--r--application/api/ApiUtils.php51
-rw-r--r--application/api/controllers/ApiController.php54
-rw-r--r--application/api/controllers/Info.php42
-rw-r--r--application/api/exceptions/ApiAuthorizationException.php34
-rw-r--r--application/api/exceptions/ApiBadParametersException.php19
-rw-r--r--application/api/exceptions/ApiException.php77
-rw-r--r--application/api/exceptions/ApiInternalException.php19
-rw-r--r--application/config/ConfigManager.php4
11 files changed, 480 insertions, 1 deletions
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
256 256
257 return true; 257 return true;
258 } 258 }
259
260 /**
261 * Initialize API settings:
262 * - api.enabled: true
263 * - api.secret: generated secret
264 */
265 public function updateMethodApiSettings()
266 {
267 if ($this->conf->exists('api.secret')) {
268 return true;
269 }
270
271 $this->conf->set('api.enabled', true);
272 $this->conf->set(
273 'api.secret',
274 generate_api_secret(
275 $this->conf->get('credentials.login'),
276 $this->conf->get('credentials.salt')
277 )
278 );
279 $this->conf->write($this->isLoggedIn);
280 return true;
281 }
259} 282}
260 283
261/** 284/**
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)
231 } 231 }
232 setlocale(LC_ALL, $attempts); 232 setlocale(LC_ALL, $attempts);
233} 233}
234
235/**
236 * Generates a default API secret.
237 *
238 * Note that the random-ish methods used in this function are predictable,
239 * which makes them NOT suitable for crypto.
240 * BUT the random string is salted with the salt and hashed with the username.
241 * It makes the generated API secret secured enough for Shaarli.
242 *
243 * PHP 7 provides random_int(), designed for cryptography.
244 * More info: http://stackoverflow.com/questions/4356289/php-random-string-generator
245
246 * @param string $username Shaarli login username
247 * @param string $salt Shaarli password hash salt
248 *
249 * @return string|bool Generated API secret, 12 char length.
250 * Or false if invalid parameters are provided (which will make the API unusable).
251 */
252function generate_api_secret($username, $salt)
253{
254 if (empty($username) || empty($salt)) {
255 return false;
256 }
257
258 return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
259}
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 @@
1<?php
2
3namespace Shaarli\Api;
4
5use Shaarli\Api\Exceptions\ApiException;
6use Shaarli\Api\Exceptions\ApiAuthorizationException;
7use Slim\Container;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class ApiMiddleware
13 *
14 * This will be called before accessing any API Controller.
15 * Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
16 *
17 * If the request is validated, the controller is called, otherwise a JSON error response is returned.
18 *
19 * @package Api
20 */
21class ApiMiddleware
22{
23 /**
24 * @var int JWT token validity in seconds (9 min).
25 */
26 public static $TOKEN_DURATION = 540;
27
28 /**
29 * @var Container: contains conf, plugins, etc.
30 */
31 protected $container;
32
33 /**
34 * @var \ConfigManager instance.
35 */
36 protected $conf;
37
38 /**
39 * ApiMiddleware constructor.
40 *
41 * @param Container $container instance.
42 */
43 public function __construct($container)
44 {
45 $this->container = $container;
46 $this->conf = $this->container->get('conf');
47 $this->setLinkDb($this->conf);
48 }
49
50 /**
51 * Middleware execution:
52 * - check the API request
53 * - execute the controller
54 * - return the response
55 *
56 * @param Request $request Slim request
57 * @param Response $response Slim response
58 * @param callable $next Next action
59 *
60 * @return Response response.
61 */
62 public function __invoke($request, $response, $next)
63 {
64 try {
65 $this->checkRequest($request);
66 $response = $next($request, $response);
67 } catch(ApiException $e) {
68 $e->setResponse($response);
69 $e->setDebug($this->conf->get('dev.debug', false));
70 $response = $e->getApiResponse();
71 }
72
73 return $response;
74 }
75
76 /**
77 * Check the request validity (HTTP method, request value, etc.),
78 * that the API is enabled, and the JWT token validity.
79 *
80 * @param Request $request Slim request
81 *
82 * @throws ApiAuthorizationException The API is disabled or the token is invalid.
83 */
84 protected function checkRequest($request)
85 {
86 if (! $this->conf->get('api.enabled', true)) {
87 throw new ApiAuthorizationException('API is disabled');
88 }
89 $this->checkToken($request);
90 }
91
92 /**
93 * Check that the JWT token is set and valid.
94 * The API secret setting must be set.
95 *
96 * @param Request $request Slim request
97 *
98 * @throws ApiAuthorizationException The token couldn't be validated.
99 */
100 protected function checkToken($request) {
101 $jwt = $request->getHeaderLine('jwt');
102 if (empty($jwt)) {
103 throw new ApiAuthorizationException('JWT token not provided');
104 }
105
106 if (empty($this->conf->get('api.secret'))) {
107 throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
108 }
109
110 ApiUtils::validateJwtToken($jwt, $this->conf->get('api.secret'));
111 }
112
113 /**
114 * Instantiate a new LinkDB including private links,
115 * and load in the Slim container.
116 *
117 * FIXME! LinkDB could use a refactoring to avoid this trick.
118 *
119 * @param \ConfigManager $conf instance.
120 */
121 protected function setLinkDb($conf)
122 {
123 $linkDb = new \LinkDB(
124 $conf->get('resource.datastore'),
125 true,
126 $conf->get('privacy.hide_public_links'),
127 $conf->get('redirector.url'),
128 $conf->get('redirector.encode_url')
129 );
130 $this->container['db'] = $linkDb;
131 }
132}
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 @@
1<?php
2
3namespace Shaarli\Api;
4
5use Shaarli\Api\Exceptions\ApiAuthorizationException;
6
7/**
8 * Class ApiUtils
9 *
10 * Utility functions for the API.
11 */
12class ApiUtils
13{
14 /**
15 * Validates a JWT token authenticity.
16 *
17 * @param string $token JWT token extracted from the headers.
18 * @param string $secret API secret set in the settings.
19 *
20 * @throws ApiAuthorizationException the token is not valid.
21 */
22 public static function validateJwtToken($token, $secret)
23 {
24 $parts = explode('.', $token);
25 if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
26 throw new ApiAuthorizationException('Malformed JWT token');
27 }
28
29 $genSign = hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret);
30 if ($parts[2] != $genSign) {
31 throw new ApiAuthorizationException('Invalid JWT signature');
32 }
33
34 $header = json_decode(base64_decode($parts[0]));
35 if ($header === null) {
36 throw new ApiAuthorizationException('Invalid JWT header');
37 }
38
39 $payload = json_decode(base64_decode($parts[1]));
40 if ($payload === null) {
41 throw new ApiAuthorizationException('Invalid JWT payload');
42 }
43
44 if (empty($payload->iat)
45 || $payload->iat > time()
46 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
47 ) {
48 throw new ApiAuthorizationException('Invalid JWT issued time');
49 }
50 }
51}
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 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use \Slim\Container;
6
7/**
8 * Abstract Class ApiController
9 *
10 * Defines REST API Controller dependencies injected from the container.
11 *
12 * @package Api\Controllers
13 */
14abstract class ApiController
15{
16 /**
17 * @var Container
18 */
19 protected $ci;
20
21 /**
22 * @var \ConfigManager
23 */
24 protected $conf;
25
26 /**
27 * @var \LinkDB
28 */
29 protected $linkDb;
30
31 /**
32 * @var int|null JSON style option.
33 */
34 protected $jsonStyle;
35
36 /**
37 * ApiController constructor.
38 *
39 * Note: enabling debug mode displays JSON with readable formatting.
40 *
41 * @param Container $ci Slim container.
42 */
43 public function __construct(Container $ci)
44 {
45 $this->ci = $ci;
46 $this->conf = $ci->get('conf');
47 $this->linkDb = $ci->get('db');
48 if ($this->conf->get('dev.debug', false)) {
49 $this->jsonStyle = JSON_PRETTY_PRINT;
50 } else {
51 $this->jsonStyle = null;
52 }
53 }
54}
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 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Slim\Http\Request;
6use Slim\Http\Response;
7
8/**
9 * Class Info
10 *
11 * REST API Controller: /info
12 *
13 * @package Api\Controllers
14 * @see http://shaarli.github.io/api-documentation/#links-instance-information-get
15 */
16class Info extends ApiController
17{
18 /**
19 * Service providing various information about Shaarli instance.
20 *
21 * @param Request $request Slim request.
22 * @param Response $response Slim response.
23 *
24 * @return Response response.
25 */
26 public function getInfo($request, $response)
27 {
28 $info = [
29 'global_counter' => count($this->linkDb),
30 'private_counter' => count_private($this->linkDb),
31 'settings' => array(
32 'title' => $this->conf->get('general.title', 'Shaarli'),
33 'header_link' => $this->conf->get('general.header_link', '?'),
34 'timezone' => $this->conf->get('general.timezone', 'UTC'),
35 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
36 'default_private_links' => $this->conf->get('privacy.default_private_links', false),
37 ),
38 ];
39
40 return $response->withJson($info, 200, $this->jsonStyle);
41 }
42}
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 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiAuthorizationException
7 *
8 * Request not authorized, return a 401 HTTP code.
9 */
10class ApiAuthorizationException extends ApiException
11{
12 /**
13 * {@inheritdoc}
14 */
15 public function getApiResponse()
16 {
17 $this->setMessage('Not authorized');
18 return $this->buildApiResponse(401);
19 }
20
21 /**
22 * Set the exception message.
23 *
24 * We only return a generic error message in production mode to avoid giving
25 * to much security information.
26 *
27 * @param $message string the exception message.
28 */
29 public function setMessage($message)
30 {
31 $original = $this->debug === true ? ': '. $this->getMessage() : '';
32 $this->message = $message . $original;
33 }
34}
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 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiBadParametersException
7 *
8 * Invalid request exception, return a 400 HTTP code.
9 */
10class ApiBadParametersException extends ApiException
11{
12 /**
13 * {@inheritdoc}
14 */
15 public function getApiResponse()
16 {
17 return $this->buildApiResponse(400);
18 }
19}
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 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5use Slim\Http\Response;
6
7/**
8 * Abstract class ApiException
9 *
10 * Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
11 * Also can include various information in debug mode.
12 */
13abstract class ApiException extends \Exception {
14
15 /**
16 * @var Response instance from Slim.
17 */
18 protected $response;
19
20 /**
21 * @var bool Debug mode enabled/disabled.
22 */
23 protected $debug;
24
25 /**
26 * Build the final response.
27 *
28 * @return Response Final response to give.
29 */
30 public abstract function getApiResponse();
31
32 /**
33 * Creates ApiResponse body.
34 * In production mode, it will only return the exception message,
35 * but in dev mode, it includes additional information in an array.
36 *
37 * @return array|string response body
38 */
39 protected function getApiResponseBody() {
40 if ($this->debug !== true) {
41 return $this->getMessage();
42 }
43 return [
44 'message' => $this->getMessage(),
45 'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
46 ];
47 }
48
49 /**
50 * Build the Response object to return.
51 *
52 * @param int $code HTTP status.
53 *
54 * @return Response with status + body.
55 */
56 protected function buildApiResponse($code)
57 {
58 $style = $this->debug ? JSON_PRETTY_PRINT : null;
59 return $this->response->withJson($this->getApiResponseBody(), $code, $style);
60 }
61
62 /**
63 * @param Response $response
64 */
65 public function setResponse($response)
66 {
67 $this->response = $response;
68 }
69
70 /**
71 * @param bool $debug
72 */
73 public function setDebug($debug)
74 {
75 $this->debug = $debug;
76 }
77}
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 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiInternalException
7 *
8 * Generic exception, return a 500 HTTP code.
9 */
10class ApiInternalException extends ApiException
11{
12 /**
13 * @inheritdoc
14 */
15 public function getApiResponse()
16 {
17 return $this->buildApiResponse(500);
18 }
19}
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
20 */ 20 */
21 protected static $NOT_FOUND = 'NOT_FOUND'; 21 protected static $NOT_FOUND = 'NOT_FOUND';
22 22
23 public static $DEFAULT_PLUGINS = array('qrcode');
24
23 /** 25 /**
24 * @var string Config folder. 26 * @var string Config folder.
25 */ 27 */
@@ -308,7 +310,7 @@ class ConfigManager
308 310
309 $this->setEmpty('general.header_link', '?'); 311 $this->setEmpty('general.header_link', '?');
310 $this->setEmpty('general.links_per_page', 20); 312 $this->setEmpty('general.links_per_page', 20);
311 $this->setEmpty('general.enabled_plugins', array('qrcode')); 313 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
312 314
313 $this->setEmpty('updates.check_updates', false); 315 $this->setEmpty('updates.check_updates', false);
314 $this->setEmpty('updates.check_updates_branch', 'stable'); 316 $this->setEmpty('updates.check_updates_branch', 'stable');