diff --git a/lib/Database.php b/lib/Database.php index dc2c74d5..01cd91ad 100644 --- a/lib/Database.php +++ b/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: diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index edd5f771..cec575b1 100644 --- a/lib/Db/MySQL/Driver.php +++ b/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 { diff --git a/sql/MySQL/4.sql b/sql/MySQL/4.sql index aa073a6e..bde12122 100644 --- a/sql/MySQL/4.sql +++ b/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'; diff --git a/sql/PostgreSQL/4.sql b/sql/PostgreSQL/4.sql index e0cd8eb7..60962115 100644 --- a/sql/PostgreSQL/4.sql +++ b/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'; diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index 38176450..7f213e1b 100644 --- a/sql/SQLite3/1.sql +++ b/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; diff --git a/sql/SQLite3/4.sql b/sql/SQLite3/4.sql index aa7cfbd8..f7cdd20f 100644 --- a/sql/SQLite3/4.sql +++ b/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'; diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 9e140c4d..47803ffd 100644 --- a/tests/cases/Database/Base.php +++ b/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; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 9fb893b2..5340fcc7 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/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' => [ diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php index f8b4199b..6d80a7eb 100644 --- a/tests/cases/Database/SeriesCleanup.php +++ b/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); + } } diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php index c7cd2a4d..a01f0644 100644 --- a/tests/cases/Database/SeriesFeed.php +++ b/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' => [ diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 99c9f1ae..9643b64b 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/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' => [ diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index e6fc426e..9ffc01bc 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/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' => [ diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php index c9867420..74a809c1 100644 --- a/tests/cases/Database/SeriesSession.php +++ b/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' => [ diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index 0adac9e6..9756a281 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/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' => [ diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index 7c5aa1c5..404e2f1b 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/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' => [ diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php new file mode 100644 index 00000000..738fc58b --- /dev/null +++ b/tests/cases/Database/SeriesToken.php @@ -0,0 +1,135 @@ +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"); + } +} diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php index 49c324b9..991577a0 100644 --- a/tests/cases/Database/SeriesUser.php +++ b/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); }