aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2016-12-15 10:13:00 +0100
committerArthurHoaro <arthur@hoa.ro>2016-12-15 10:36:00 +0100
commit18e6796726d73d7dc90ecdd16c181493941f5487 (patch)
tree17159284be5072b505eead31efdc064b6d5a35d0
parent423ab02846286f94276d21e38ca1e296646618bf (diff)
downloadShaarli-18e6796726d73d7dc90ecdd16c181493941f5487.tar.gz
Shaarli-18e6796726d73d7dc90ecdd16c181493941f5487.tar.zst
Shaarli-18e6796726d73d7dc90ecdd16c181493941f5487.zip
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.
-rw-r--r--.htaccess4
-rw-r--r--CHANGELOG.md2
-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
-rw-r--r--composer.json10
-rw-r--r--index.php44
-rw-r--r--tests/api/ApiMiddlewareTest.php184
-rw-r--r--tests/api/ApiUtilsTest.php206
-rw-r--r--tests/api/controllers/InfoTest.php113
-rw-r--r--tpl/configure.html2
-rw-r--r--tpl/install.html2
18 files changed, 983 insertions, 16 deletions
diff --git a/.htaccess b/.htaccess
new file mode 100644
index 00000000..66ef8f69
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,4 @@
1RewriteEngine On
2RewriteCond %{REQUEST_FILENAME} !-f
3RewriteCond %{REQUEST_FILENAME} !-d
4RewriteRule ^ index.php [QSA,L]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf5a85e2..fe775b3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
11 11
12### Added 12### Added
13 13
14- REST API: see [Shaarli API documentation](http://shaarli.github.io/api-documentation/)
15
14### Changed 16### Changed
15 17
16### Fixed 18### Fixed
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');
diff --git a/composer.json b/composer.json
index 40b725d3..4786fe94 100644
--- a/composer.json
+++ b/composer.json
@@ -12,12 +12,20 @@
12 "require": { 12 "require": {
13 "php": ">=5.5", 13 "php": ">=5.5",
14 "shaarli/netscape-bookmark-parser": "1.*", 14 "shaarli/netscape-bookmark-parser": "1.*",
15 "erusev/parsedown": "1.6" 15 "erusev/parsedown": "1.6",
16 "slim/slim": "^3.0"
16 }, 17 },
17 "require-dev": { 18 "require-dev": {
18 "phpmd/phpmd" : "@stable", 19 "phpmd/phpmd" : "@stable",
19 "phpunit/phpunit": "4.8.*", 20 "phpunit/phpunit": "4.8.*",
20 "sebastian/phpcpd": "*", 21 "sebastian/phpcpd": "*",
21 "squizlabs/php_codesniffer": "2.*" 22 "squizlabs/php_codesniffer": "2.*"
23 },
24 "autoload": {
25 "psr-4": {
26 "Shaarli\\Api\\": "application/api/",
27 "Shaarli\\Api\\Controllers\\": "application/api/controllers",
28 "Shaarli\\Api\\Exceptions\\": "application/api/exceptions"
29 }
22 } 30 }
23} 31}
diff --git a/index.php b/index.php
index 25e37b32..835fd7d2 100644
--- a/index.php
+++ b/index.php
@@ -175,7 +175,6 @@ define('STAY_SIGNED_IN_TOKEN', sha1($conf->get('credentials.hash') . $_SERVER['R
175if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { 175if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
176 autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']); 176 autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']);
177} 177}
178header('Content-Type: text/html; charset=utf-8'); // We use UTF-8 for proper international characters handling.
179 178
180/** 179/**
181 * Checking session state (i.e. is the user still logged in) 180 * Checking session state (i.e. is the user still logged in)
@@ -731,17 +730,10 @@ function showLinkList($PAGE, $LINKSDB, $conf, $pluginManager) {
731 * 730 *
732 * @param ConfigManager $conf Configuration Manager instance. 731 * @param ConfigManager $conf Configuration Manager instance.
733 * @param PluginManager $pluginManager Plugin Manager instance, 732 * @param PluginManager $pluginManager Plugin Manager instance,
733 * @param LinkDB $LINKSDB
734 */ 734 */
735function renderPage($conf, $pluginManager) 735function renderPage($conf, $pluginManager, $LINKSDB)
736{ 736{
737 $LINKSDB = new LinkDB(
738 $conf->get('resource.datastore'),
739 isLoggedIn(),
740 $conf->get('privacy.hide_public_links'),
741 $conf->get('redirector.url'),
742 $conf->get('redirector.encode_url')
743 );
744
745 $updater = new Updater( 737 $updater = new Updater(
746 read_updates_file($conf->get('resource.updates')), 738 read_updates_file($conf->get('resource.updates')),
747 $LINKSDB, 739 $LINKSDB,
@@ -938,7 +930,7 @@ function renderPage($conf, $pluginManager)
938 exit; 930 exit;
939 } 931 }
940 932
941 // Display openseach plugin (XML) 933 // Display opensearch plugin (XML)
942 if ($targetPage == Router::$PAGE_OPENSEARCH) { 934 if ($targetPage == Router::$PAGE_OPENSEARCH) {
943 header('Content-Type: application/xml; charset=utf-8'); 935 header('Content-Type: application/xml; charset=utf-8');
944 $PAGE->assign('serverurl', index_url($_SERVER)); 936 $PAGE->assign('serverurl', index_url($_SERVER));
@@ -2226,4 +2218,32 @@ if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=
2226if (!isset($_SESSION['LINKS_PER_PAGE'])) { 2218if (!isset($_SESSION['LINKS_PER_PAGE'])) {
2227 $_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20); 2219 $_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20);
2228} 2220}
2229renderPage($conf, $pluginManager); 2221
2222$linkDb = new LinkDB(
2223 $conf->get('resource.datastore'),
2224 isLoggedIn(),
2225 $conf->get('privacy.hide_public_links'),
2226 $conf->get('redirector.url'),
2227 $conf->get('redirector.encode_url')
2228);
2229
2230$container = new \Slim\Container();
2231$container['conf'] = $conf;
2232$container['plugins'] = $pluginManager;
2233$app = new \Slim\App($container);
2234
2235// REST API routes
2236$app->group('/api/v1', function() {
2237 $this->get('/info', '\Api\Controllers\Info:getInfo');
2238})->add('\Api\ApiMiddleware');
2239
2240$response = $app->run(true);
2241// Hack to make Slim and Shaarli router work together:
2242// If a Slim route isn't found, we call renderPage().
2243if ($response->getStatusCode() == 404) {
2244 // We use UTF-8 for proper international characters handling.
2245 header('Content-Type: text/html; charset=utf-8');
2246 renderPage($conf, $pluginManager, $linkDb);
2247} else {
2248 $app->respond($response);
2249}
diff --git a/tests/api/ApiMiddlewareTest.php b/tests/api/ApiMiddlewareTest.php
new file mode 100644
index 00000000..4d4dd9b9
--- /dev/null
+++ b/tests/api/ApiMiddlewareTest.php
@@ -0,0 +1,184 @@
1<?php
2
3namespace Shaarli\Api;
4
5use Slim\Container;
6use Slim\Http\Environment;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class ApiMiddlewareTest
12 *
13 * Test the REST API Slim Middleware.
14 *
15 * Note that we can't test a valid use case here, because the middleware
16 * needs to call a valid controller/action during its execution.
17 *
18 * @package Api
19 */
20class ApiMiddlewareTest extends \PHPUnit_Framework_TestCase
21{
22 /**
23 * @var string datastore to test write operations
24 */
25 protected static $testDatastore = 'sandbox/datastore.php';
26
27 /**
28 * @var \ConfigManager instance
29 */
30 protected $conf;
31
32 /**
33 * @var \ReferenceLinkDB instance.
34 */
35 protected $refDB = null;
36
37 /**
38 * @var Container instance.
39 */
40 protected $container;
41
42 /**
43 * Before every test, instantiate a new Api with its config, plugins and links.
44 */
45 public function setUp()
46 {
47 $this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
48 $this->conf->set('api.secret', 'NapoleonWasALizard');
49
50 $this->refDB = new \ReferenceLinkDB();
51 $this->refDB->write(self::$testDatastore);
52
53 $this->container = new Container();
54 $this->container['conf'] = $this->conf;
55 }
56
57 /**
58 * After every test, remove the test datastore.
59 */
60 public function tearDown()
61 {
62 @unlink(self::$testDatastore);
63 }
64
65 /**
66 * Invoke the middleware with the API disabled:
67 * should return a 401 error Unauthorized.
68 */
69 public function testInvokeMiddlewareApiDisabled()
70 {
71 $this->conf->set('api.enabled', false);
72 $mw = new ApiMiddleware($this->container);
73 $env = Environment::mock([
74 'REQUEST_METHOD' => 'GET',
75 'REQUEST_URI' => '/echo',
76 ]);
77 $request = Request::createFromEnvironment($env);
78 $response = new Response();
79 /** @var Response $response */
80 $response = $mw($request, $response, null);
81
82 $this->assertEquals(401, $response->getStatusCode());
83 $body = json_decode((string) $response->getBody());
84 $this->assertEquals('Not authorized', $body);
85 }
86
87 /**
88 * Invoke the middleware with the API disabled in debug mode:
89 * should return a 401 error Unauthorized - with a specific message and a stacktrace.
90 */
91 public function testInvokeMiddlewareApiDisabledDebug()
92 {
93 $this->conf->set('api.enabled', false);
94 $this->conf->set('dev.debug', true);
95 $mw = new ApiMiddleware($this->container);
96 $env = Environment::mock([
97 'REQUEST_METHOD' => 'GET',
98 'REQUEST_URI' => '/echo',
99 ]);
100 $request = Request::createFromEnvironment($env);
101 $response = new Response();
102 /** @var Response $response */
103 $response = $mw($request, $response, null);
104
105 $this->assertEquals(401, $response->getStatusCode());
106 $body = json_decode((string) $response->getBody());
107 $this->assertEquals('Not authorized: API is disabled', $body->message);
108 $this->assertContains('ApiAuthorizationException', $body->stacktrace);
109 }
110
111 /**
112 * Invoke the middleware without a token (debug):
113 * should return a 401 error Unauthorized - with a specific message and a stacktrace.
114 */
115 public function testInvokeMiddlewareNoTokenProvidedDebug()
116 {
117 $this->conf->set('dev.debug', true);
118 $mw = new ApiMiddleware($this->container);
119 $env = Environment::mock([
120 'REQUEST_METHOD' => 'GET',
121 'REQUEST_URI' => '/echo',
122 ]);
123 $request = Request::createFromEnvironment($env);
124 $response = new Response();
125 /** @var Response $response */
126 $response = $mw($request, $response, null);
127
128 $this->assertEquals(401, $response->getStatusCode());
129 $body = json_decode((string) $response->getBody());
130 $this->assertEquals('Not authorized: JWT token not provided', $body->message);
131 $this->assertContains('ApiAuthorizationException', $body->stacktrace);
132 }
133
134 /**
135 * Invoke the middleware without a secret set in settings (debug):
136 * should return a 401 error Unauthorized - with a specific message and a stacktrace.
137 */
138 public function testInvokeMiddlewareNoSecretSetDebug()
139 {
140 $this->conf->set('dev.debug', true);
141 $this->conf->set('api.secret', '');
142 $mw = new ApiMiddleware($this->container);
143 $env = Environment::mock([
144 'REQUEST_METHOD' => 'GET',
145 'REQUEST_URI' => '/echo',
146 'HTTP_JWT'=> 'jwt',
147 ]);
148 $request = Request::createFromEnvironment($env);
149 $response = new Response();
150 /** @var Response $response */
151 $response = $mw($request, $response, null);
152
153 $this->assertEquals(401, $response->getStatusCode());
154 $body = json_decode((string) $response->getBody());
155 $this->assertEquals('Not authorized: Token secret must be set in Shaarli\'s administration', $body->message);
156 $this->assertContains('ApiAuthorizationException', $body->stacktrace);
157 }
158
159 /**
160 * Invoke the middleware without an invalid JWT token (debug):
161 * should return a 401 error Unauthorized - with a specific message and a stacktrace.
162 *
163 * Note: specific JWT errors tests are handled in ApiUtilsTest.
164 */
165 public function testInvokeMiddlewareInvalidJwtDebug()
166 {
167 $this->conf->set('dev.debug', true);
168 $mw = new ApiMiddleware($this->container);
169 $env = Environment::mock([
170 'REQUEST_METHOD' => 'GET',
171 'REQUEST_URI' => '/echo',
172 'HTTP_JWT'=> 'bad jwt',
173 ]);
174 $request = Request::createFromEnvironment($env);
175 $response = new Response();
176 /** @var Response $response */
177 $response = $mw($request, $response, null);
178
179 $this->assertEquals(401, $response->getStatusCode());
180 $body = json_decode((string) $response->getBody());
181 $this->assertEquals('Not authorized: Malformed JWT token', $body->message);
182 $this->assertContains('ApiAuthorizationException', $body->stacktrace);
183 }
184}
diff --git a/tests/api/ApiUtilsTest.php b/tests/api/ApiUtilsTest.php
new file mode 100644
index 00000000..10da1459
--- /dev/null
+++ b/tests/api/ApiUtilsTest.php
@@ -0,0 +1,206 @@
1<?php
2
3namespace Shaarli\Api;
4
5/**
6 * Class ApiUtilsTest
7 */
8class ApiUtilsTest extends \PHPUnit_Framework_TestCase
9{
10 /**
11 * Force the timezone for ISO datetimes.
12 */
13 public static function setUpBeforeClass()
14 {
15 date_default_timezone_set('UTC');
16 }
17
18 /**
19 * Generate a valid JWT token.
20 *
21 * @param string $secret API secret used to generate the signature.
22 *
23 * @return string Generated token.
24 */
25 public static function generateValidJwtToken($secret)
26 {
27 $header = base64_encode('{
28 "typ": "JWT",
29 "alg": "HS512"
30 }');
31 $payload = base64_encode('{
32 "iat": '. time() .'
33 }');
34 $signature = hash_hmac('sha512', $header .'.'. $payload , $secret);
35 return $header .'.'. $payload .'.'. $signature;
36 }
37
38 /**
39 * Generate a JWT token from given header and payload.
40 *
41 * @param string $header Header in JSON format.
42 * @param string $payload Payload in JSON format.
43 * @param string $secret API secret used to hash the signature.
44 *
45 * @return string JWT token.
46 */
47 public static function generateCustomJwtToken($header, $payload, $secret)
48 {
49 $header = base64_encode($header);
50 $payload = base64_encode($payload);
51 $signature = hash_hmac('sha512', $header . '.' . $payload, $secret);
52 return $header . '.' . $payload . '.' . $signature;
53 }
54
55 /**
56 * Test validateJwtToken() with a valid JWT token.
57 */
58 public function testValidateJwtTokenValid()
59 {
60 $secret = 'WarIsPeace';
61 ApiUtils::validateJwtToken(self::generateValidJwtToken($secret), $secret);
62 }
63
64 /**
65 * Test validateJwtToken() with a malformed JWT token.
66 *
67 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
68 * @expectedExceptionMessage Malformed JWT token
69 */
70 public function testValidateJwtTokenMalformed()
71 {
72 $token = 'ABC.DEF';
73 ApiUtils::validateJwtToken($token, 'foo');
74 }
75
76 /**
77 * Test validateJwtToken() with an empty JWT token.
78 *
79 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
80 * @expectedExceptionMessage Malformed JWT token
81 */
82 public function testValidateJwtTokenMalformedEmpty()
83 {
84 $token = false;
85 ApiUtils::validateJwtToken($token, 'foo');
86 }
87
88 /**
89 * Test validateJwtToken() with a JWT token without header.
90 *
91 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
92 * @expectedExceptionMessage Malformed JWT token
93 */
94 public function testValidateJwtTokenMalformedEmptyHeader()
95 {
96 $token = '.payload.signature';
97 ApiUtils::validateJwtToken($token, 'foo');
98 }
99
100 /**
101 * Test validateJwtToken() with a JWT token without payload
102 *
103 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
104 * @expectedExceptionMessage Malformed JWT token
105 */
106 public function testValidateJwtTokenMalformedEmptyPayload()
107 {
108 $token = 'header..signature';
109 ApiUtils::validateJwtToken($token, 'foo');
110 }
111
112 /**
113 * Test validateJwtToken() with a JWT token with an empty signature.
114 *
115 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
116 * @expectedExceptionMessage Invalid JWT signature
117 */
118 public function testValidateJwtTokenInvalidSignatureEmpty()
119 {
120 $token = 'header.payload.';
121 ApiUtils::validateJwtToken($token, 'foo');
122 }
123
124 /**
125 * Test validateJwtToken() with a JWT token with an invalid signature.
126 *
127 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
128 * @expectedExceptionMessage Invalid JWT signature
129 */
130 public function testValidateJwtTokenInvalidSignature()
131 {
132 $token = 'header.payload.nope';
133 ApiUtils::validateJwtToken($token, 'foo');
134 }
135
136 /**
137 * Test validateJwtToken() with a JWT token with a signature generated with the wrong API secret.
138 *
139 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
140 * @expectedExceptionMessage Invalid JWT signature
141 */
142 public function testValidateJwtTokenInvalidSignatureSecret()
143 {
144 ApiUtils::validateJwtToken(self::generateValidJwtToken('foo'), 'bar');
145 }
146
147 /**
148 * Test validateJwtToken() with a JWT token with a an invalid header (not JSON).
149 *
150 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
151 * @expectedExceptionMessage Invalid JWT header
152 */
153 public function testValidateJwtTokenInvalidHeader()
154 {
155 $token = $this->generateCustomJwtToken('notJSON', '{"JSON":1}', 'secret');
156 ApiUtils::validateJwtToken($token, 'secret');
157 }
158
159 /**
160 * Test validateJwtToken() with a JWT token with a an invalid payload (not JSON).
161 *
162 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
163 * @expectedExceptionMessage Invalid JWT payload
164 */
165 public function testValidateJwtTokenInvalidPayload()
166 {
167 $token = $this->generateCustomJwtToken('{"JSON":1}', 'notJSON', 'secret');
168 ApiUtils::validateJwtToken($token, 'secret');
169 }
170
171 /**
172 * Test validateJwtToken() with a JWT token without issued time.
173 *
174 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
175 * @expectedExceptionMessage Invalid JWT issued time
176 */
177 public function testValidateJwtTokenInvalidTimeEmpty()
178 {
179 $token = $this->generateCustomJwtToken('{"JSON":1}', '{"JSON":1}', 'secret');
180 ApiUtils::validateJwtToken($token, 'secret');
181 }
182
183 /**
184 * Test validateJwtToken() with an expired JWT token.
185 *
186 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
187 * @expectedExceptionMessage Invalid JWT issued time
188 */
189 public function testValidateJwtTokenInvalidTimeExpired()
190 {
191 $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() - 600) . '}', 'secret');
192 ApiUtils::validateJwtToken($token, 'secret');
193 }
194
195 /**
196 * Test validateJwtToken() with a JWT token issued in the future.
197 *
198 * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
199 * @expectedExceptionMessage Invalid JWT issued time
200 */
201 public function testValidateJwtTokenInvalidTimeFuture()
202 {
203 $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret');
204 ApiUtils::validateJwtToken($token, 'secret');
205 }
206}
diff --git a/tests/api/controllers/InfoTest.php b/tests/api/controllers/InfoTest.php
new file mode 100644
index 00000000..2916eed8
--- /dev/null
+++ b/tests/api/controllers/InfoTest.php
@@ -0,0 +1,113 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Slim\Container;
6use Slim\Http\Environment;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class InfoTest
12 *
13 * Test REST API controller Info.
14 *
15 * @package Api\Controllers
16 */
17class InfoTest extends \PHPUnit_Framework_TestCase
18{
19 /**
20 * @var string datastore to test write operations
21 */
22 protected static $testDatastore = 'sandbox/datastore.php';
23
24 /**
25 * @var \ConfigManager instance
26 */
27 protected $conf;
28
29 /**
30 * @var \ReferenceLinkDB instance.
31 */
32 protected $refDB = null;
33
34 /**
35 * @var Container instance.
36 */
37 protected $container;
38
39 /**
40 * @var Info controller instance.
41 */
42 protected $controller;
43
44 /**
45 * Before every test, instantiate a new Api with its config, plugins and links.
46 */
47 public function setUp()
48 {
49 $this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
50 $this->refDB = new \ReferenceLinkDB();
51 $this->refDB->write(self::$testDatastore);
52
53 $this->container = new Container();
54 $this->container['conf'] = $this->conf;
55 $this->container['db'] = new \LinkDB(self::$testDatastore, true, false);
56
57 $this->controller = new Info($this->container);
58 }
59
60 /**
61 * After every test, remove the test datastore.
62 */
63 public function tearDown()
64 {
65 @unlink(self::$testDatastore);
66 }
67
68 /**
69 * Test /info service.
70 */
71 public function testGetInfo()
72 {
73 $env = Environment::mock([
74 'REQUEST_METHOD' => 'GET',
75 ]);
76 $request = Request::createFromEnvironment($env);
77
78 $response = $this->controller->getInfo($request, new Response());
79 $this->assertEquals(200, $response->getStatusCode());
80 $data = json_decode((string) $response->getBody(), true);
81
82 $this->assertEquals(8, $data['global_counter']);
83 $this->assertEquals(2, $data['private_counter']);
84 $this->assertEquals('Shaarli', $data['settings']['title']);
85 $this->assertEquals('?', $data['settings']['header_link']);
86 $this->assertEquals('UTC', $data['settings']['timezone']);
87 $this->assertEquals(\ConfigManager::$DEFAULT_PLUGINS, $data['settings']['enabled_plugins']);
88 $this->assertEquals(false, $data['settings']['default_private_links']);
89
90 $title = 'My links';
91 $headerLink = 'http://shaarli.tld';
92 $timezone = 'Europe/Paris';
93 $enabledPlugins = array('foo', 'bar');
94 $defaultPrivateLinks = true;
95 $this->conf->set('general.title', $title);
96 $this->conf->set('general.header_link', $headerLink);
97 $this->conf->set('general.timezone', $timezone);
98 $this->conf->set('general.enabled_plugins', $enabledPlugins);
99 $this->conf->set('privacy.default_private_links', $defaultPrivateLinks);
100
101 $response = $this->controller->getInfo($request, new Response());
102 $this->assertEquals(200, $response->getStatusCode());
103 $data = json_decode((string) $response->getBody(), true);
104
105 $this->assertEquals(8, $data['global_counter']);
106 $this->assertEquals(2, $data['private_counter']);
107 $this->assertEquals($title, $data['settings']['title']);
108 $this->assertEquals($headerLink, $data['settings']['header_link']);
109 $this->assertEquals($timezone, $data['settings']['timezone']);
110 $this->assertEquals($enabledPlugins, $data['settings']['enabled_plugins']);
111 $this->assertEquals($defaultPrivateLinks, $data['settings']['default_private_links']);
112 }
113}
diff --git a/tpl/configure.html b/tpl/configure.html
index a015770e..b4197bf9 100644
--- a/tpl/configure.html
+++ b/tpl/configure.html
@@ -81,7 +81,7 @@
81 </td> 81 </td>
82 </tr> 82 </tr>
83 <tr> 83 <tr>
84 <td valign="top"><b>Enable API</b></td> 84 <td valign="top"><b>Enable REST API</b></td>
85 <td> 85 <td>
86 <input type="checkbox" name="apiEnabled" id="apiEnabled" 86 <input type="checkbox" name="apiEnabled" id="apiEnabled"
87 {if="$api_enabled"}checked{/if}/> 87 {if="$api_enabled"}checked{/if}/>
diff --git a/tpl/install.html b/tpl/install.html
index eda4c54d..42874dcd 100644
--- a/tpl/install.html
+++ b/tpl/install.html
@@ -21,7 +21,7 @@
21 <td> 21 <td>
22 <input type="checkbox" name="enableApi" id="enableApi" checked="checked"> 22 <input type="checkbox" name="enableApi" id="enableApi" checked="checked">
23 <label for="enableApi"> 23 <label for="enableApi">
24 &nbsp;Enable Shaarli's API. 24 &nbsp;Enable Shaarli's REST API.
25 Allow third party software to use Shaarli such as mobile application. 25 Allow third party software to use Shaarli such as mobile application.
26 </label> 26 </label>
27 </td> 27 </td>