1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-22 13:12:41 +00:00

Add HTTP authentication support to TTRSS; fixes #133

Also bump version to 0.4.0
This commit is contained in:
J. King 2018-10-26 14:40:20 -04:00
parent b4b2b10db3
commit 1aa556cf12
13 changed files with 509 additions and 63 deletions

View file

@ -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)
==========================

View file

@ -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/

View file

@ -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;

View file

@ -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";

View file

@ -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 {

View file

@ -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;
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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;
}
}

View file

@ -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]],
[[], []],
];
}

View file

@ -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 = [

View file

@ -20,11 +20,13 @@ use Phake;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon<extended> */
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"));
}
}

View file

@ -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);
}
}