Explorar el Código

Add access tokens to the db, with relevant code

Tokens are similar to sessions in that they stand in for users, but the
protocol handlers will manage them; Fever login hashes are the
originating use case for them. These must never expire, for example,
and we need to specify their values.

This commit also performs a bit of database clean-up
microsub
J. King hace 5 años
padre
commit
38bdde1167
  1. 54
      lib/Database.php
  2. 2
      lib/Db/MySQL/Driver.php
  3. 18
      sql/MySQL/4.sql
  4. 17
      sql/PostgreSQL/4.sql
  5. 4
      sql/SQLite3/1.sql
  6. 53
      sql/SQLite3/4.sql
  7. 1
      tests/cases/Database/Base.php
  8. 9
      tests/cases/Database/SeriesArticle.php
  9. 30
      tests/cases/Database/SeriesCleanup.php
  10. 5
      tests/cases/Database/SeriesFeed.php
  11. 5
      tests/cases/Database/SeriesFolder.php
  12. 9
      tests/cases/Database/SeriesLabel.php
  13. 5
      tests/cases/Database/SeriesSession.php
  14. 5
      tests/cases/Database/SeriesSubscription.php
  15. 9
      tests/cases/Database/SeriesTag.php
  16. 135
      tests/cases/Database/SeriesToken.php
  17. 12
      tests/cases/Database/SeriesUser.php

54
lib/Database.php

@ -27,6 +27,7 @@ use JKingWeb\Arsse\Misc\ValueInfo;
* - Editions, identifying authorial modifications to articles
* - Labels, which belong to users and can be assigned to multiple articles
* - Sessions, used by some protocols to identify users across periods of time
* - Tokens, similar to sessions, but with more control over their properties
* - Metadata, used internally by the server
*
* The various methods of this class perform operations on these things, with
@ -380,6 +381,59 @@ class Database {
return (($now + $diff) >= $expiry->getTimestamp());
}
/** Creates a new token for the given user in the given class
*
* @param string $user The user for whom to create the token
* @param string $class The class of the token e.g. the protocol name
* @param string|null $id The value of the token; if none is provided a UUID will be generated
* @param \DateTimeInterface|null $expires An optional expiry date and time for the token
*/
public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null): string {
// If the user isn't authorized to perform this action then throw an exception.
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// generate a token if it's not provided
$id = $id ?? UUID::mint()->hex;
// save the token to the database
$this->db->prepare("INSERT INTO arsse_tokens(id,class,\"user\",expires) values(?,?,?,?)", "str", "str", "str", "datetime")->run($id, $class, $user, $expires);
// return the ID
return $id;
}
/** Revokes one or all tokens for a user in a class
*
* @param string $user The user who owns the token to be revoked
* @param string $class The class of the token e.g. the protocol name
* @param string|null $id The ID of a specific token, or null for all tokens in the class
*/
public function tokenRevoke(string $user, string $class, string $id = null): bool {
// If the user isn't authorized to perform this action then throw an exception.
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (is_null($id)) {
$out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ?", "str", "str")->run($user, $class)->changes();
} else {
$out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ? and id = ?", "str", "str", "str")->run($user, $class, $id)->changes();
}
return (bool) $out;
}
/** Look up data associated with a token */
public function tokenLookup(string $class, string $id): array {
$out = $this->db->prepare("SELECT id,class,\"user\",created,expires from arsse_tokens where class = ? and id = ? and expires > CURRENT_TIMESTAMP", "str", "str")->run($class, $id)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "token", 'id' => $id]);
}
return $out;
}
/** Deletes expires tokens from the database, returning the number of deleted tokens */
public function tokenCleanup(): int {
return $this->db->query("DELETE FROM arsse_tokens where expires < CURRENT_TIMESTAMP")->changes();
}
/** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder
*
* The $data array may contain the following keys:

2
lib/Db/MySQL/Driver.php

@ -41,7 +41,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
$this->exec($q);
}
// get the maximum packet size; parameter strings larger than this size need to be chunked
$this->packetSize = (int) $this->query("select variable_value from performance_schema.session_variables where variable_name = 'max_allowed_packet'")->getValue();
$this->packetSize = (int) $this->query("SELECT variable_value from performance_schema.session_variables where variable_name = 'max_allowed_packet'")->getValue();
}
public static function makeSetupQueries(): array {

18
sql/MySQL/4.sql

@ -20,4 +20,22 @@ create table arsse_tag_members(
primary key(tag,subscription)
) character set utf8mb4 collate utf8mb4_unicode_ci;
create table arsse_tokens(
id varchar(255) not null,
class varchar(255) not null,
"user" varchar(255) not null references arsse_users(id) on delete cascade on update cascade,
created datetime(0) not null default CURRENT_TIMESTAMP,
expires datetime(0),
primary key(id,class)
) character set utf8mb4 collate utf8mb4_unicode_ci;
alter table arsse_users drop column name;
alter table arsse_users drop column avatar_type;
alter table arsse_users drop column avatar_data;
alter table arsse_users drop column admin;
alter table arsse_users drop column rights;
drop table arsse_users_meta;
update arsse_meta set value = '5' where "key" = 'schema_version';

17
sql/PostgreSQL/4.sql

@ -20,4 +20,21 @@ create table arsse_tag_members(
primary key(tag,subscription)
);
create table arsse_tokens(
id text,
class text not null,
"user" text not null references arsse_users(id) on delete cascade on update cascade,
created timestamp(0) without time zone not null default CURRENT_TIMESTAMP,
expires timestamp(0) without time zone,
primary key(id,class)
);
alter table arsse_users drop column name;
alter table arsse_users drop column avatar_type;
alter table arsse_users drop column avatar_data;
alter table arsse_users drop column admin;
alter table arsse_users drop column rights;
drop table arsse_users_meta;
update arsse_meta set value = '5' where "key" = 'schema_version';

4
sql/SQLite3/1.sql

@ -5,8 +5,8 @@
create table arsse_sessions(
-- sessions for Tiny Tiny RSS (and possibly others)
id text primary key, -- UUID of session
created text not null default CURRENT_TIMESTAMP, -- Session start timestamp
expires text not null, -- Time at which session is no longer valid
created text not null default CURRENT_TIMESTAMP, -- session start timestamp
expires text not null, -- time at which session is no longer valid
user text not null references arsse_users(id) on delete cascade on update cascade -- user associated with the session
) without rowid;

53
sql/SQLite3/4.sql

@ -20,6 +20,59 @@ create table arsse_tag_members(
primary key(tag,subscription) -- only one association of a given tag to a given subscription
) without rowid;
create table arsse_tokens(
-- access tokens that are managed by the protocol handler and may optionally expire
id text, -- token identifier
class text not null, -- symbolic name of the protocol handler managing the token
user text not null references arsse_users(id) on delete cascade on update cascade, -- user associated with the token
created text not null default CURRENT_TIMESTAMP, -- creation timestamp
expires text, -- time at which token is no longer valid
primary key(id,class) -- tokens must be unique for their class
) without rowid;
-- clean up the user tables to remove unused stuff
-- if any of the removed things are implemented in future, necessary structures will be added back in at that time
create table arsse_users_new(
-- users
id text primary key not null collate nocase, -- user id
password text -- password, salted and hashed; if using external authentication this would be blank
) without rowid;
insert into arsse_users_new select id,password from arsse_users;
drop table arsse_users;
alter table arsse_users_new rename to arsse_users;
drop table arsse_users_meta;
-- use WITHOUT ROWID tables when possible; this is an SQLite-specific change
create table arsse_meta_new(
-- application metadata
key text primary key not null, -- metadata key
value text -- metadata value, serialized as a string
) without rowid;
insert into arsse_meta_new select * from arsse_meta;
drop table arsse_meta;
alter table arsse_meta_new rename to arsse_meta;
create table arsse_marks_new(
-- users' actions on newsfeed entries
article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks
subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user
read boolean not null default 0, -- whether the article has been read
starred boolean not null default 0, -- whether the article is starred
modified text, -- time at which an article was last modified by a given user
note text not null default '', -- Tiny Tiny RSS freeform user note
touched boolean not null default 0, -- used to indicate a record has been modified during the course of some transactions
primary key(article,subscription) -- no more than one mark-set per article per user
) without rowid;
insert into arsse_marks_new select * from arsse_marks;
drop table arsse_marks;
alter table arsse_marks_new rename to arsse_marks;
-- set version marker
pragma user_version = 5;
update arsse_meta set value = '5' where "key" = 'schema_version';

1
tests/cases/Database/Base.php

@ -20,6 +20,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest {
use SeriesMeta;
use SeriesUser;
use SeriesSession;
use SeriesToken;
use SeriesFolder;
use SeriesFeed;
use SeriesSubscription;

9
tests/cases/Database/SeriesArticle.php

@ -19,13 +19,12 @@ trait SeriesArticle {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["john.doe@example.org", "", "John Doe"],
["john.doe@example.net", "", "John Doe"],
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
["john.doe@example.org", ""],
["john.doe@example.net", ""],
],
],
'arsse_feeds' => [

30
tests/cases/Database/SeriesCleanup.php

@ -29,11 +29,10 @@ trait SeriesCleanup {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
],
],
'arsse_sessions' => [
@ -51,6 +50,20 @@ trait SeriesCleanup {
["e", $daysago, $nowish, "jane.doe@example.com"], // created more than a day ago and expired, thus deleted
],
],
'arsse_tokens' => [
'columns' => [
'id' => "str",
'class' => "str",
'user' => "str",
'expires' => "datetime",
],
'rows' => [
["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff],
["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $weeksago], // expired
["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null],
["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $soon],
],
],
'arsse_feeds' => [
'columns' => [
'id' => "int",
@ -226,4 +239,15 @@ trait SeriesCleanup {
}
$this->compareExpectations($state);
}
public function testCleanUpExpiredTokens() {
Arsse::$db->tokenCleanup();
$state = $this->primeExpectations($this->data, [
'arsse_tokens' => ["id", "class"]
]);
foreach ([2] as $id) {
unset($state['arsse_tokens']['rows'][$id - 1]);
}
$this->compareExpectations($state);
}
}

5
tests/cases/Database/SeriesFeed.php

@ -22,11 +22,10 @@ trait SeriesFeed {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
],
],
'arsse_feeds' => [

5
tests/cases/Database/SeriesFolder.php

@ -16,11 +16,10 @@ trait SeriesFolder {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
],
],
'arsse_folders' => [

9
tests/cases/Database/SeriesLabel.php

@ -18,13 +18,12 @@ trait SeriesLabel {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["john.doe@example.org", "", "John Doe"],
["john.doe@example.net", "", "John Doe"],
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
["john.doe@example.org", ""],
["john.doe@example.net", ""],
],
],
'arsse_folders' => [

5
tests/cases/Database/SeriesSession.php

@ -27,11 +27,10 @@ trait SeriesSession {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
],
],
'arsse_sessions' => [

5
tests/cases/Database/SeriesSubscription.php

@ -18,11 +18,10 @@ trait SeriesSubscription {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
],
],
'arsse_folders' => [

9
tests/cases/Database/SeriesTag.php

@ -17,13 +17,12 @@ trait SeriesTag {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["john.doe@example.org", "", "John Doe"],
["john.doe@example.net", "", "John Doe"],
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
["john.doe@example.org", ""],
["john.doe@example.net", ""],
],
],
'arsse_feeds' => [

135
tests/cases/Database/SeriesToken.php

@ -0,0 +1,135 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date;
use Phake;
trait SeriesToken {
protected function setUpSeriesToken() {
// set up the test data
$past = gmdate("Y-m-d H:i:s", strtotime("now - 1 minute"));
$future = gmdate("Y-m-d H:i:s", strtotime("now + 1 minute"));
$faroff = gmdate("Y-m-d H:i:s", strtotime("now + 1 hour"));
$old = gmdate("Y-m-d H:i:s", strtotime("now - 2 days"));
$this->data = [
'arsse_users' => [
'columns' => [
'id' => 'str',
'password' => 'str',
],
'rows' => [
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
],
],
'arsse_tokens' => [
'columns' => [
'id' => "str",
'class' => "str",
'user' => "str",
'expires' => "datetime",
],
'rows' => [
["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff],
["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past], // expired
["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null],
["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future],
],
],
];
}
protected function tearDownSeriesToken() {
unset($this->data);
}
public function testLookUpAValidToken() {
$exp1 = [
'id' => "80fa94c1a11f11e78667001e673b2560",
'class' => "fever.login",
'user' => "jane.doe@example.com"
];
$exp2 = [
'id' => "da772f8fa13c11e78667001e673b2560",
'class' => "class.class",
'user' => "john.doe@example.com"
];
$this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560"));
$this->assertArraySubset($exp2, Arsse::$db->tokenLookup("class.class", "da772f8fa13c11e78667001e673b2560"));
// token lookup should not check authorization
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560"));
}
public function testLookUpAMissingToken() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->tokenLookup("class", "thisTokenDoesNotExist");
}
public function testLookUpAnExpiredToken() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->tokenLookup("fever.login", "27c6de8da13311e78667001e673b2560");
}
public function testLookUpATokenOfTheWrongClass() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->tokenLookup("some.class", "80fa94c1a11f11e78667001e673b2560");
}
public function testCreateAToken() {
$user = "jane.doe@example.com";
$state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "class", "expires", "user"]]);
$id = Arsse::$db->tokenCreate($user, "fever.login");
$state['arsse_tokens']['rows'][] = [$id, "fever.login", null, $user];
$this->compareExpectations($state);
$id = Arsse::$db->tokenCreate($user, "fever.login", null, new \DateTime("2020-01-01T00:00:00Z"));
$state['arsse_tokens']['rows'][] = [$id, "fever.login", "2020-01-01 00:00:00", $user];
$this->compareExpectations($state);
Arsse::$db->tokenCreate($user, "fever.login", "token!", new \DateTime("2021-01-01T00:00:00Z"));
$state['arsse_tokens']['rows'][] = ["token!", "fever.login", "2021-01-01 00:00:00", $user];
$this->compareExpectations($state);
}
public function testCreateATokenWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->tokenCreate("fever.login", "jane.doe@example.com");
}
public function testRevokeAToken() {
$user = "jane.doe@example.com";
$id = "80fa94c1a11f11e78667001e673b2560";
$this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login", $id));
$state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]);
unset($state['arsse_tokens']['rows'][0]);
$this->compareExpectations($state);
// revoking a token which does not exist is not an error
$this->assertFalse(Arsse::$db->tokenRevoke($user, "fever.login", $id));
}
public function testRevokeAllTokens() {
$user = "jane.doe@example.com";
$state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]);
$this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login"));
unset($state['arsse_tokens']['rows'][0]);
unset($state['arsse_tokens']['rows'][1]);
$this->compareExpectations($state);
$this->assertTrue(Arsse::$db->tokenRevoke($user, "class.class"));
unset($state['arsse_tokens']['rows'][2]);
$this->compareExpectations($state);
// revoking tokens which do not exist is not an error
$this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class"));
}
public function testRevokeATokenWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->tokenRevoke("jane.doe@example.com", "fever.login");
}
}

12
tests/cases/Database/SeriesUser.php

@ -17,13 +17,11 @@ trait SeriesUser {
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
'rights' => 'int',
],
'rows' => [
["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', "Hard Lip Herbert", 100], // password is hash of "secret"
["jane.doe@example.com", "", "Jane Doe", 0],
["john.doe@example.com", "", "John Doe", 0],
["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW'], // password is hash of "secret"
["jane.doe@example.com", ""],
["john.doe@example.com", ""],
],
],
];
@ -68,8 +66,8 @@ trait SeriesUser {
public function testAddANewUser() {
$this->assertTrue(Arsse::$db->userAdd("john.doe@example.org", ""));
Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userAdd");
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id','name','rights']]);
$state['arsse_users']['rows'][] = ["john.doe@example.org", null, 0];
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]);
$state['arsse_users']['rows'][] = ["john.doe@example.org"];
$this->compareExpectations($state);
}

Cargando…
Cancelar
Guardar