From 1aa556cf12e6b6168709cea6c4925fce99aa3eac Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Oct 2018 14:40:20 -0400 Subject: [PATCH] Add HTTP authentication support to TTRSS; fixes #133 Also bump version to 0.4.0 --- CHANGELOG | 7 + README.md | 16 + lib/Arsse.php | 2 +- lib/Conf.php | 10 +- lib/Database.php | 12 +- lib/REST.php | 10 +- lib/REST/TinyTinyRSS/API.php | 33 +- lib/REST/TinyTinyRSS/Icon.php | 9 +- lib/User.php | 14 +- tests/cases/REST/TestREST.php | 14 +- tests/cases/REST/TinyTinyRSS/TestAPI.php | 359 ++++++++++++++++++++-- tests/cases/REST/TinyTinyRSS/TestIcon.php | 64 +++- tests/lib/Database/SeriesSubscription.php | 22 ++ 13 files changed, 509 insertions(+), 63 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 61f87f5..f4d0027 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,10 @@ +Version 0.4.0 (2018-10-26) +========================== + +New features: +- Support for HTTP authentication in Tiny Tiny RSS (see README.md for detais) +- New userHTTPAuthRequired and userSessionEnforced settings + Version 0.3.1 (2018-07-22) ========================== diff --git a/README.md b/README.md index 1340235..e39c1cf 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,22 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - The documentation for the `getCompactHeadlines` operation states the default value for `limit` is 20, but the reference implementation defaults to unlimited; The Arsse also defaults to unlimited - It is assumed TTRSS exposes undocumented behaviour; unless otherwise noted The Arsse only implements documented behaviour +#### Interaction with HTTP authentication + +Tiny Tiny RSS itself is unaware of HTTP authentication: if HTTP authentication is used in the server configuration, it has no effect on authentication in the API. The Arsse, however, makes use of HTTP authentication for NextCloud News, and can do so for TTRSS as well. In a default configuration The Arsse functions in the same way as TTRSS: HTTP authentication and API authentication are completely separate and independent. Behaviour is modified in the following circumstances: + +- If the `userHTTPAuthRequired` setting is `true`: + - Clients must pass HTTP authentication; API authentication then proceeds as normal +- If the `userSessionEnforced` setting is `false`: + - Clients may optionally provide HTTP credentials; if they are valid API authentication is skipped: tokens are issued upon login, but ignored for HTTP-authenticated requests +- If the `userHTTPAuthRequired` setting is `true` and the `userSessionEnforced` setting is `false`: + - Clients must pass HTTP authentication; API authentication is skipped: tokens are issued upon login, but thereafter ignored +- If the `userPreAuth` setting is `true`: + - The Web server asserts authentication was successful; API authentication only checks that HTTP and API user names match +- If the `userPreAuth` setting is `true` and the `userSessionEnforced` setting is `false`: + - The Web server asserts authentication was successful; API authentication is skipped: tokens are issued upon login, but thereafter ignored + +In all cases, supplying invalid HTTP credentials will result in a 401 response. [newIssue]: https://code.mensbeam.com/MensBeam/arsse/issues/new [Composer]: https://getcomposer.org/ diff --git a/lib/Arsse.php b/lib/Arsse.php index 3c945c3..fc7ba4b 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; class Arsse { - const VERSION = "0.3.1"; + const VERSION = "0.4.0"; /** @var Lang */ public static $lang; diff --git a/lib/Conf.php b/lib/Conf.php index cc0a183..0a35747 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -30,11 +30,15 @@ class Conf { public $userDriver = User\Internal\Driver::class; /** @var boolean Whether users are already authenticated by the Web server before the application is executed */ public $userPreAuth = false; + /** @var boolean Whether to require successful HTTP authentication before processing API-level authentication for protocols which have any. Normally the Tiny Tiny RSS relies on its own session-token authentication scheme, for example */ + public $userHTTPAuthRequired = false; /** @var integer Desired length of temporary user passwords */ public $userTempPasswordLength = 20; + /** @var boolean Whether invalid or expired API session tokens should prevent logging in when HTTP authentication is used, for protocol which implement their own authentication */ + public $userSessionEnforced = true; /** @var string Period of inactivity after which log-in sessions should be considered invalid, as an ISO 8601 duration (default: 24 hours) * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ - public $userSessionTimeout = "PT24H"; + public $userSessionTimeout = "PT24H"; /** @var string Maximum lifetime of log-in sessions regardless of activity, as an ISO 8601 duration (default: 7 days); * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $userSessionLifetime = "P7D"; @@ -64,10 +68,10 @@ class Conf { /** @var string When to delete a feed from the database after all its subscriptions have been deleted, as an ISO 8601 duration (default: 24 hours; empty string for never) * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ - public $purgeFeeds = "PT24H"; + public $purgeFeeds = "PT24H"; /** @var string When to delete an unstarred article in the database after it has been marked read by all users, as an ISO 8601 duration (default: 7 days; empty string for never) * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ - public $purgeArticlesRead = "P7D"; + public $purgeArticlesRead = "P7D"; /** @var string When to delete an unstarred article in the database regardless of its read state, as an ISO 8601 duration (default: 21 days; empty string for never) * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $purgeArticlesUnread = "P21D"; diff --git a/lib/Database.php b/lib/Database.php index c5c4e65..21af33c 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -646,8 +646,16 @@ class Database { return $out; } - public function subscriptionFavicon(int $id): string { - return (string) $this->db->prepare("SELECT favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id where arsse_subscriptions.id = ?", "int")->run($id)->getValue(); + public function subscriptionFavicon(int $id, string $user = null): string { + $q = new Query("SELECT favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id"); + $q->setWhere("arsse_subscriptions.id = ?", "int", $id); + if (isset($user)) { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $q->setWhere("arsse_subscriptions.owner = ?", "str", $user); + } + return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { diff --git a/lib/REST.php b/lib/REST.php index ce49f17..fa457a9 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -134,9 +134,13 @@ class REST { } elseif (isset($env['REMOTE_USER'])) { $user = $env['REMOTE_USER']; } - if (strlen($user) && Arsse::$user->auth($user, $password)) { - $req = $req->withAttribute("authenticated", true); - $req = $req->withAttribute("authenticatedUser", $user); + if (strlen($user)) { + if (Arsse::$user->auth($user, $password)) { + $req = $req->withAttribute("authenticated", true); + $req = $req->withAttribute("authenticatedUser", $user); + } else { + $req = $req->withAttribute("authenticationFailed", true); + } } return $req; } diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 6e1935a..eb0aaad 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -118,6 +118,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } catch (ExceptionType $e) { throw new Exception("INCORRECT_USAGE"); } + if ($req->getAttribute("authenticated", false)) { + // if HTTP authentication was successfully used, set the expected user ID + Arsse::$user->id = $req->getAttribute("authenticatedUser"); + } elseif (Arsse::$conf->userHTTPAuthRequired || Arsse::$conf->userPreAuth || $req->getAttribute("authenticationFailed", false)) { + // otherwise if HTTP authentication failed or is required, deny access at the HTTP level + return new EmptyResponse(401); + } if (strtolower((string) $data['op']) != "login") { // unless logging in, a session identifier is required $this->resumeSession((string) $data['sid']); @@ -148,6 +155,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function resumeSession(string $id): bool { + // if HTTP authentication was successful and sessions are not enforced, proceed unconditionally + if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) { + return true; + } try { // verify the supplied session is valid $s = Arsse::$db->sessionResume($id); @@ -172,16 +183,24 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opLogin(array $data): array { - // both cleartext and base64 passwords are accepted - if (Arsse::$user->auth($data['user'], $data['password']) || Arsse::$user->auth($data['user'], base64_decode($data['password']))) { - $id = Arsse::$db->sessionCreate($data['user']); - return [ - 'session_id' => $id, - 'api_level' => self::LEVEL - ]; + $user = $data['user'] ?? ""; + $pass = $data['password'] ?? ""; + if (!Arsse::$conf->userSessionEnforced && isset(Arsse::$user->id)) { + // if HTTP authentication was previously successful and sessions + // are not enforced, create a session for the HTTP user regardless + // of which user the API call mentions + $id = Arsse::$db->sessionCreate(Arsse::$user->id); + } elseif ((!Arsse::$conf->userPreAuth && (Arsse::$user->auth($user, $pass) || Arsse::$user->auth($user, base64_decode($pass)))) || (Arsse::$conf->userPreAuth && Arsse::$user->id===$user)) { + // otherwise both cleartext and base64 passwords are accepted + // if pre-authentication is in use, just make sure the user names match + $id = Arsse::$db->sessionCreate($user); } else { throw new Exception("LOGIN_ERROR"); } + return [ + 'session_id' => $id, + 'api_level' => self::LEVEL + ]; } public function opLogout(array $data): array { diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php index ef2d0c0..678dc8c 100644 --- a/lib/REST/TinyTinyRSS/Icon.php +++ b/lib/REST/TinyTinyRSS/Icon.php @@ -16,13 +16,20 @@ class Icon extends \JKingWeb\Arsse\REST\AbstractHandler { } public function dispatch(ServerRequestInterface $req): ResponseInterface { + if ($req->getAttribute("authenticated", false)) { + // if HTTP authentication was successfully used, set the expected user ID + Arsse::$user->id = $req->getAttribute("authenticatedUser"); + } elseif ($req->getAttribute("authenticationFailed", false) || Arsse::$conf->userHTTPAuthRequired) { + // otherwise if HTTP authentication failed or did not occur when it is required, deny access at the HTTP level + return new Response(401); + } if ($req->getMethod() != "GET") { // only GET requests are allowed return new Response(405, ['Allow' => "GET"]); } elseif (!preg_match("<^(\d+)\.ico$>", $req->getRequestTarget(), $match) || !((int) $match[1])) { return new Response(404); } - $url = Arsse::$db->subscriptionFavicon((int) $match[1]); + $url = Arsse::$db->subscriptionFavicon((int) $match[1], Arsse::$user->id ?? null); if ($url) { // strip out anything after literal line-end characters; this is to mitigate a potential header (e.g. cookie) injection from the URL if (($pos = strpos($url, "\r")) !== false || ($pos = strpos($url, "\n")) !== false) { diff --git a/lib/User.php b/lib/User.php index 94b38dc..2c2ad05 100644 --- a/lib/User.php +++ b/lib/User.php @@ -140,6 +140,7 @@ class User { if ($user===null) { return $this->authHTTP(); } else { + $prevUser = $this->id ?? null; $this->id = $user; $this->actor = []; switch ($this->u->driverFunctions("auth")) { @@ -152,20 +153,25 @@ class User { if ($out && !Arsse::$db->userExists($user)) { $this->autoProvision($user, $password); } - return $out; + break; case User\Driver::FUNC_INTERNAL: if (Arsse::$conf->userPreAuth) { if (!Arsse::$db->userExists($user)) { $this->autoProvision($user, $password); } - return true; + $out = true; } else { - return $this->u->auth($user, $password); + $out = $this->u->auth($user, $password); } break; case User\Driver::FUNCT_NOT_IMPLEMENTED: - return false; + $out = false; + break; + } + if (!$out) { + $this->id = $prevUser; } + return $out; } } diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php index 1f78524..dac6b25 100644 --- a/tests/cases/REST/TestREST.php +++ b/tests/cases/REST/TestREST.php @@ -66,9 +66,10 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { $r = new REST(); // create a mock user manager Arsse::$user = Phake::mock(User::class); - Phake::when(Arsse::$user)->auth->thenReturn(true); - Phake::when(Arsse::$user)->auth($this->anything(), "superman")->thenReturn(false); - Phake::when(Arsse::$user)->auth("jane.doe@example.com", $this->anything())->thenReturn(false); + Phake::when(Arsse::$user)->auth->thenReturn(false); + Phake::when(Arsse::$user)->auth("john.doe@example.com", "secret")->thenReturn(true); + Phake::when(Arsse::$user)->auth("john.doe@example.com", "")->thenReturn(true); + Phake::when(Arsse::$user)->auth("someone.else@example.com", "")->thenReturn(true); // create an input server request $req = new ServerRequest($serverParams); // create the expected output @@ -84,11 +85,12 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { return [ [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "secret"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]], [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "secret", 'REMOTE_USER' => "jane.doe@example.com"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]], - [['PHP_AUTH_USER' => "jane.doe@example.com", 'PHP_AUTH_PW' => "secret"], []], - [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "superman"], []], + [['PHP_AUTH_USER' => "jane.doe@example.com", 'PHP_AUTH_PW' => "secret"], ['authenticationFailed' => true]], + [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "superman"], ['authenticationFailed' => true]], [['REMOTE_USER' => "john.doe@example.com"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]], [['REMOTE_USER' => "someone.else@example.com"], ['authenticated' => true, 'authenticatedUser' => "someone.else@example.com"]], - [['REMOTE_USER' => "jane.doe@example.com"], []], + [['REMOTE_USER' => "jane.doe@example.com"], ['authenticationFailed' => true]], + [[], []], ]; } diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 12a9056..278d1cf 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -129,7 +129,7 @@ LONG_STRING; return $value; } - protected function req($data, string $method = "POST", string $target = "", string $strData = null): ResponseInterface { + protected function req($data, string $method = "POST", string $target = "", string $strData = null, string $user = null): ResponseInterface { $url = "/tt-rss/api".$target; $server = [ 'REQUEST_METHOD' => $method, @@ -144,8 +144,19 @@ LONG_STRING; $body->write(json_encode($data)); } $req = $req->withBody($body)->withRequestTarget($target); + if (isset($user)) { + if (strlen($user)) { + $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); + } else { + $req = $req->withAttribute("authenticationFailed", true); + } + } return $this->h->dispatch($req); } + + protected function reqAuth($data, $user) { + return $this->req($data, "POST", "", null, $user); + } protected function respGood($content = null, $seq = 0): Response { return new Response([ @@ -211,30 +222,326 @@ LONG_STRING; $this->assertMessage($exp, $this->req(null, "POST", "", "This is not valid JSON data")); $this->assertMessage($exp, $this->req(null, "POST", "", "")); // lack of data is also an error } - - public function testLogIn() { - Phake::when(Arsse::$user)->auth(Arsse::$user->id, $this->anything())->thenReturn(false); - Phake::when(Arsse::$user)->auth(Arsse::$user->id, "secret")->thenReturn(true); - Phake::when(Arsse::$db)->sessionCreate->thenReturn("PriestsOfSyrinx")->thenReturn("SolarFederation"); - $data = [ - 'op' => "login", - 'user' => Arsse::$user->id, - 'password' => "secret", - ]; - $exp = $this->respGood(['session_id' => "PriestsOfSyrinx", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertMessage($exp, $this->req($data)); + + /** @dataProvider provideLoginRequests */ + public function testLogIn(array $conf, $httpUser, array $data, $sessions) { + Arsse::$user->id = null; + Arsse::$conf = (new Conf)->import($conf); + Phake::when(Arsse::$user)->auth->thenReturn(false); + Phake::when(Arsse::$user)->auth("john.doe@example.com", "secret")->thenReturn(true); + Phake::when(Arsse::$user)->auth("jane.doe@example.com", "superman")->thenReturn(true); + Phake::when(Arsse::$db)->sessionCreate("john.doe@example.com")->thenReturn("PriestsOfSyrinx")->thenReturn("SolarFederation"); + Phake::when(Arsse::$db)->sessionCreate("jane.doe@example.com")->thenReturn("ClockworkAngels")->thenReturn("SevenCitiesOfGold"); + if ($sessions instanceof EmptyResponse) { + $exp1 = $sessions; + $exp2 = $sessions; + } elseif ($sessions) { + $exp1 = $this->respGood(['session_id' => $sessions[0], 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); + $exp2 = $this->respGood(['session_id' => $sessions[1], 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); + } else { + $exp1 = $this->respErr("LOGIN_ERROR"); + $exp2 = $this->respErr("LOGIN_ERROR"); + } + $data['op'] = "login"; + $this->assertMessage($exp1, $this->reqAuth($data, $httpUser)); // base64 passwords are also accepted - $data['password'] = base64_encode($data['password']); - $exp = $this->respGood(['session_id' => "SolarFederation", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertMessage($exp, $this->req($data)); - // test a failed log-in - $data['password'] = "superman"; - $exp = $this->respErr("LOGIN_ERROR"); - $this->assertMessage($exp, $this->req($data)); + if(isset($data['password'])) { + $data['password'] = base64_encode($data['password']); + } + $this->assertMessage($exp2, $this->reqAuth($data, $httpUser)); // logging in should never try to resume a session Phake::verify(Arsse::$db, Phake::times(0))->sessionResume($this->anything()); } + public function provideLoginRequests() { + return $this->generateLoginRequests("login"); + } + + /** @dataProvider provideResumeRequests */ + public function testValidateASession(array $conf, $httpUser, string $data, $result) { + Arsse::$user->id = null; + Arsse::$conf = (new Conf)->import($conf); + Phake::when(Arsse::$db)->sessionResume("PriestsOfSyrinx")->thenReturn([ + 'id' => "PriestsOfSyrinx", + 'created' => "2000-01-01 00:00:00", + 'expires' => "2112-12-21 21:12:00", + 'user' => "john.doe@example.com", + ]); + Phake::when(Arsse::$db)->sessionResume("ClockworkAngels")->thenReturn([ + 'id' => "ClockworkAngels", + 'created' => "2000-01-01 00:00:00", + 'expires' => "2112-12-21 21:12:00", + 'user' => "jane.doe@example.com", + ]); + $data = [ + 'op' => "isLoggedIn", + 'sid' => $data, + ]; + if ($result instanceof EmptyResponse) { + $exp1 = $result; + $exp2 = null; + } elseif ($result) { + $exp1 = $this->respGood(['status' => true]); + $exp2 = $result; + } else { + $exp1 = $this->respErr("NOT_LOGGED_IN"); + $exp2 = ($httpUser) ? $httpUser : null; + } + $this->assertMessage($exp1, $this->reqAuth($data, $httpUser)); + $this->assertSame($exp2, Arsse::$user->id); + } + + public function provideResumeRequests() { + return $this->generateLoginRequests("isLoggedIn"); + } + + public function generateLoginRequests(string $type) { + $john = "john.doe@example.com"; + $johnGood = [ + 'user' => $john, + 'password' => "secret", + ]; + $johnBad = [ + 'user' => $john, + 'password' => "superman", + ]; + $johnSess = ["PriestsOfSyrinx", "SolarFederation"]; + $jane = "jane.doe@example.com"; + $janeGood = [ + 'user' => $jane, + 'password' => "superman", + ]; + $janeBad = [ + 'user' => $jane, + 'password' => "secret", + ]; + $janeSess = ["ClockworkAngels", "SevenCitiesOfGold"]; + $missingU = [ + 'password' => "secret", + ]; + $missingP = [ + 'user' => $john, + ]; + $sidJohn = "PriestsOfSyrinx"; + $sidJane = "ClockworkAngels"; + $sidBad = "TheWatchmaker"; + $defaults = [ + 'userPreAuth' => false, + 'userHTTPAuthRequired' => false, + 'userSessionEnforced' => true, + ]; + $preAuth = [ + 'userPreAuth' => true, + 'userHTTPAuthRequired' => false, // implied true by pre-auth + 'userSessionEnforced' => true, + ]; + $httpReq = [ + 'userPreAuth' => false, + 'userHTTPAuthRequired' => true, + 'userSessionEnforced' => true, + ]; + $noSess = [ + 'userPreAuth' => false, + 'userHTTPAuthRequired' => false, + 'userSessionEnforced' => false, + ]; + $fullHttp = [ + 'userPreAuth' => false, + 'userHTTPAuthRequired' => true, + 'userSessionEnforced' => false, + ]; + $http401 = new EmptyResponse(401); + if ($type=="login") { + return [ + // conf, user, data, result + [$defaults, null, $johnGood, $johnSess], + [$defaults, null, $johnBad, false], + [$defaults, null, $janeGood, $janeSess], + [$defaults, null, $janeBad, false], + [$defaults, null, $missingU, false], + [$defaults, null, $missingP, false], + [$defaults, $john, $johnGood, $johnSess], + [$defaults, $john, $johnBad, false], + [$defaults, $john, $janeGood, $janeSess], + [$defaults, $john, $janeBad, false], + [$defaults, $john, $missingU, false], + [$defaults, $john, $missingP, false], + [$defaults, $jane, $johnGood, $johnSess], + [$defaults, $jane, $johnBad, false], + [$defaults, $jane, $janeGood, $janeSess], + [$defaults, $jane, $janeBad, false], + [$defaults, $jane, $missingU, false], + [$defaults, $jane, $missingP, false], + [$defaults, "", $johnGood, $http401], + [$defaults, "", $johnBad, $http401], + [$defaults, "", $janeGood, $http401], + [$defaults, "", $janeBad, $http401], + [$defaults, "", $missingU, $http401], + [$defaults, "", $missingP, $http401], + [$preAuth, null, $johnGood, $http401], + [$preAuth, null, $johnBad, $http401], + [$preAuth, null, $janeGood, $http401], + [$preAuth, null, $janeBad, $http401], + [$preAuth, null, $missingU, $http401], + [$preAuth, null, $missingP, $http401], + [$preAuth, $john, $johnGood, $johnSess], + [$preAuth, $john, $johnBad, $johnSess], + [$preAuth, $john, $janeGood, false], + [$preAuth, $john, $janeBad, false], + [$preAuth, $john, $missingU, false], + [$preAuth, $john, $missingP, $johnSess], + [$preAuth, $jane, $johnGood, false], + [$preAuth, $jane, $johnBad, false], + [$preAuth, $jane, $janeGood, $janeSess], + [$preAuth, $jane, $janeBad, $janeSess], + [$preAuth, $jane, $missingU, false], + [$preAuth, $jane, $missingP, false], + [$preAuth, "", $johnGood, $http401], + [$preAuth, "", $johnBad, $http401], + [$preAuth, "", $janeGood, $http401], + [$preAuth, "", $janeBad, $http401], + [$preAuth, "", $missingU, $http401], + [$preAuth, "", $missingP, $http401], + [$httpReq, null, $johnGood, $http401], + [$httpReq, null, $johnBad, $http401], + [$httpReq, null, $janeGood, $http401], + [$httpReq, null, $janeBad, $http401], + [$httpReq, null, $missingU, $http401], + [$httpReq, null, $missingP, $http401], + [$httpReq, $john, $johnGood, $johnSess], + [$httpReq, $john, $johnBad, false], + [$httpReq, $john, $janeGood, $janeSess], + [$httpReq, $john, $janeBad, false], + [$httpReq, $john, $missingU, false], + [$httpReq, $john, $missingP, false], + [$httpReq, $jane, $johnGood, $johnSess], + [$httpReq, $jane, $johnBad, false], + [$httpReq, $jane, $janeGood, $janeSess], + [$httpReq, $jane, $janeBad, false], + [$httpReq, $jane, $missingU, false], + [$httpReq, $jane, $missingP, false], + [$httpReq, "", $johnGood, $http401], + [$httpReq, "", $johnBad, $http401], + [$httpReq, "", $janeGood, $http401], + [$httpReq, "", $janeBad, $http401], + [$httpReq, "", $missingU, $http401], + [$httpReq, "", $missingP, $http401], + [$noSess, null, $johnGood, $johnSess], + [$noSess, null, $johnBad, false], + [$noSess, null, $janeGood, $janeSess], + [$noSess, null, $janeBad, false], + [$noSess, null, $missingU, false], + [$noSess, null, $missingP, false], + [$noSess, $john, $johnGood, $johnSess], + [$noSess, $john, $johnBad, $johnSess], + [$noSess, $john, $janeGood, $johnSess], + [$noSess, $john, $janeBad, $johnSess], + [$noSess, $john, $missingU, $johnSess], + [$noSess, $john, $missingP, $johnSess], + [$noSess, $jane, $johnGood, $janeSess], + [$noSess, $jane, $johnBad, $janeSess], + [$noSess, $jane, $janeGood, $janeSess], + [$noSess, $jane, $janeBad, $janeSess], + [$noSess, $jane, $missingU, $janeSess], + [$noSess, $jane, $missingP, $janeSess], + [$noSess, "", $johnGood, $http401], + [$noSess, "", $johnBad, $http401], + [$noSess, "", $janeGood, $http401], + [$noSess, "", $janeBad, $http401], + [$noSess, "", $missingU, $http401], + [$noSess, "", $missingP, $http401], + [$fullHttp, null, $johnGood, $http401], + [$fullHttp, null, $johnBad, $http401], + [$fullHttp, null, $janeGood, $http401], + [$fullHttp, null, $janeBad, $http401], + [$fullHttp, null, $missingU, $http401], + [$fullHttp, null, $missingP, $http401], + [$fullHttp, $john, $johnGood, $johnSess], + [$fullHttp, $john, $johnBad, $johnSess], + [$fullHttp, $john, $janeGood, $johnSess], + [$fullHttp, $john, $janeBad, $johnSess], + [$fullHttp, $john, $missingU, $johnSess], + [$fullHttp, $john, $missingP, $johnSess], + [$fullHttp, $jane, $johnGood, $janeSess], + [$fullHttp, $jane, $johnBad, $janeSess], + [$fullHttp, $jane, $janeGood, $janeSess], + [$fullHttp, $jane, $janeBad, $janeSess], + [$fullHttp, $jane, $missingU, $janeSess], + [$fullHttp, $jane, $missingP, $janeSess], + [$fullHttp, "", $johnGood, $http401], + [$fullHttp, "", $johnBad, $http401], + [$fullHttp, "", $janeGood, $http401], + [$fullHttp, "", $janeBad, $http401], + [$fullHttp, "", $missingU, $http401], + [$fullHttp, "", $missingP, $http401], + ]; + } elseif ($type=="isLoggedIn") { + return [ + // conf, user, session, result + [$defaults, null, $sidJohn, $john], + [$defaults, null, $sidJane, $jane], + [$defaults, null, $sidBad, false], + [$defaults, $john, $sidJohn, $john], + [$defaults, $john, $sidJane, $jane], + [$defaults, $john, $sidBad, false], + [$defaults, $jane, $sidJohn, $john], + [$defaults, $jane, $sidJane, $jane], + [$defaults, $jane, $sidBad, false], + [$defaults, "", $sidJohn, $http401], + [$defaults, "", $sidJane, $http401], + [$defaults, "", $sidBad, $http401], + [$preAuth, null, $sidJohn, $http401], + [$preAuth, null, $sidJane, $http401], + [$preAuth, null, $sidBad, $http401], + [$preAuth, $john, $sidJohn, $john], + [$preAuth, $john, $sidJane, $jane], + [$preAuth, $john, $sidBad, false], + [$preAuth, $jane, $sidJohn, $john], + [$preAuth, $jane, $sidJane, $jane], + [$preAuth, $jane, $sidBad, false], + [$preAuth, "", $sidJohn, $http401], + [$preAuth, "", $sidJane, $http401], + [$preAuth, "", $sidBad, $http401], + [$httpReq, null, $sidJohn, $http401], + [$httpReq, null, $sidJane, $http401], + [$httpReq, null, $sidBad, $http401], + [$httpReq, $john, $sidJohn, $john], + [$httpReq, $john, $sidJane, $jane], + [$httpReq, $john, $sidBad, false], + [$httpReq, $jane, $sidJohn, $john], + [$httpReq, $jane, $sidJane, $jane], + [$httpReq, $jane, $sidBad, false], + [$httpReq, "", $sidJohn, $http401], + [$httpReq, "", $sidJane, $http401], + [$httpReq, "", $sidBad, $http401], + [$noSess, null, $sidJohn, $john], + [$noSess, null, $sidJane, $jane], + [$noSess, null, $sidBad, false], + [$noSess, $john, $sidJohn, $john], + [$noSess, $john, $sidJane, $john], + [$noSess, $john, $sidBad, $john], + [$noSess, $jane, $sidJohn, $jane], + [$noSess, $jane, $sidJane, $jane], + [$noSess, $jane, $sidBad, $jane], + [$noSess, "", $sidJohn, $http401], + [$noSess, "", $sidJane, $http401], + [$noSess, "", $sidBad, $http401], + [$fullHttp, null, $sidJohn, $http401], + [$fullHttp, null, $sidJane, $http401], + [$fullHttp, null, $sidBad, $http401], + [$fullHttp, $john, $sidJohn, $john], + [$fullHttp, $john, $sidJane, $john], + [$fullHttp, $john, $sidBad, $john], + [$fullHttp, $jane, $sidJohn, $jane], + [$fullHttp, $jane, $sidJane, $jane], + [$fullHttp, $jane, $sidBad, $jane], + [$fullHttp, "", $sidJohn, $http401], + [$fullHttp, "", $sidJane, $http401], + [$fullHttp, "", $sidBad, $http401], + ]; + } + } + public function testHandleGenericError() { Phake::when(Arsse::$user)->auth(Arsse::$user->id, $this->anything())->thenThrow(new \JKingWeb\Arsse\Db\ExceptionTimeout("general")); $data = [ @@ -257,18 +564,6 @@ LONG_STRING; Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx"); } - public function testValidateASession() { - $data = [ - 'op' => "isLoggedIn", - 'sid' => "PriestsOfSyrinx", - ]; - $exp = $this->respGood(['status' => true]); - $this->assertMessage($exp, $this->req($data)); - $data['sid'] = "SolarFederation"; - $exp = $this->respErr("NOT_LOGGED_IN"); - $this->assertMessage($exp, $this->req($data)); - } - public function testHandleUnknownMethods() { $exp = $this->respErr("UNKNOWN_METHOD", ['method' => "thisMethodDoesNotExist"]); $data = [ diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php index fd0fef7..cb74f67 100644 --- a/tests/cases/REST/TinyTinyRSS/TestIcon.php +++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php @@ -20,11 +20,13 @@ use Phake; /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon */ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { protected $h; + protected $user = "john.doe@example.com"; public function setUp() { $this->clearData(); Arsse::$conf = new Conf(); // create a mock user manager + Arsse::$user = Phake::mock(User::class); // create a mock database interface Arsse::$db = Phake::mock(Database::class); $this->h = new Icon(); @@ -34,7 +36,7 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { $this->clearData(); } - protected function req(string $target, $method = "GET"): ResponseInterface { + protected function req(string $target, string $method = "GET", string $user = null): ResponseInterface { $url = "/tt-rss/feed-icons/".$target; $server = [ 'REQUEST_METHOD' => $method, @@ -42,14 +44,29 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { ]; $req = new ServerRequest($server, [], $url, $method, "php://memory"); $req = $req->withRequestTarget($target); + if (isset($user)) { + if (strlen($user)) { + $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); + } else { + $req = $req->withAttribute("authenticationFailed", true); + } + } return $this->h->dispatch($req); } + protected function reqAuth(string $target, string $method = "GET") { + return $this->req($target, $method, $this->user); + } + + protected function reqAuthFailed(string $target, string $method = "GET") { + return $this->req($target, $method, ""); + } + public function testRetrieveFavion() { Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn(""); - Phake::when(Arsse::$db)->subscriptionFavicon(42)->thenReturn("http://example.com/favicon.ico"); - Phake::when(Arsse::$db)->subscriptionFavicon(2112)->thenReturn("http://example.net/logo.png"); - Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/"); + Phake::when(Arsse::$db)->subscriptionFavicon(42, $this->anything())->thenReturn("http://example.com/favicon.ico"); + Phake::when(Arsse::$db)->subscriptionFavicon(2112, $this->anything())->thenReturn("http://example.net/logo.png"); + Phake::when(Arsse::$db)->subscriptionFavicon(1337, $this->anything())->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/"); // these requests should succeed $exp = new Response(301, ['Location' => "http://example.com/favicon.ico"]); $this->assertMessage($exp, $this->req("42.ico")); @@ -67,4 +84,43 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { $exp = new Response(405, ['Allow' => "GET"]); $this->assertMessage($exp, $this->req("2112.ico", "PUT")); } + + public function testRetrieveFavionWithHttpAuthentication() { + $url = "http://example.org/icon.gif\r\nLocation: http://bad.example.com/"; + Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn(""); + Phake::when(Arsse::$db)->subscriptionFavicon(42, $this->user)->thenReturn($url); + Phake::when(Arsse::$db)->subscriptionFavicon(2112, "jane.doe")->thenReturn($url); + Phake::when(Arsse::$db)->subscriptionFavicon(1337, $this->user)->thenReturn($url); + Phake::when(Arsse::$db)->subscriptionFavicon(42, null)->thenReturn($url); + Phake::when(Arsse::$db)->subscriptionFavicon(2112, null)->thenReturn($url); + Phake::when(Arsse::$db)->subscriptionFavicon(1337, null)->thenReturn($url); + // these requests should succeed + $exp = new Response(301, ['Location' => "http://example.org/icon.gif"]); + $this->assertMessage($exp, $this->req("42.ico")); + $this->assertMessage($exp, $this->req("2112.ico")); + $this->assertMessage($exp, $this->req("1337.ico")); + $this->assertMessage($exp, $this->reqAuth("42.ico")); + $this->assertMessage($exp, $this->reqAuth("1337.ico")); + // these requests should fail + $exp = new Response(404); + $this->assertMessage($exp, $this->reqAuth("2112.ico")); + $exp = new Response(401); + $this->assertMessage($exp, $this->reqAuthFailed("42.ico")); + $this->assertMessage($exp, $this->reqAuthFailed("1337.ico")); + // with HTTP auth required, only authenticated requests should succeed + Arsse::$conf = (new Conf())->import(['userHTTPAuthRequired' => true]); + $exp = new Response(301, ['Location' => "http://example.org/icon.gif"]); + $this->assertMessage($exp, $this->reqAuth("42.ico")); + $this->assertMessage($exp, $this->reqAuth("1337.ico")); + // anything else should fail + $exp = new Response(401); + $this->assertMessage($exp, $this->req("42.ico")); + $this->assertMessage($exp, $this->req("2112.ico")); + $this->assertMessage($exp, $this->req("1337.ico")); + $this->assertMessage($exp, $this->reqAuthFailed("42.ico")); + $this->assertMessage($exp, $this->reqAuthFailed("1337.ico")); + // resources for the wrtong user should still fail, too + $exp = new Response(404); + $this->assertMessage($exp, $this->reqAuth("2112.ico")); + } } diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index 6b68596..a04fcf6 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -422,4 +422,26 @@ trait SeriesSubscription { // invalid IDs should simply return an empty string $this->assertSame('', Arsse::$db->subscriptionFavicon(-2112)); } + + public function testRetrieveTheFaviconOfASubscriptionWithUser() { + $exp = "http://example.com/favicon.ico"; + $user = "john.doe@example.com"; + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(1, $user)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(2, $user)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(3, $user)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(4, $user)); + $user = "jane.doe@example.com"; + $this->assertSame('', Arsse::$db->subscriptionFavicon(1, $user)); + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(2, $user)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(3, $user)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(4, $user)); + } + + public function testRetrieveTheFaviconOfASubscriptionWithUserWithoutAuthority() { + $exp = "http://example.com/favicon.ico"; + $user = "john.doe@example.com"; + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->subscriptionFavicon(-2112, $user); + } }