Browse Source

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
tags/0.8.0
J. King 9 months ago
parent
commit
38bdde1167

+ 54
- 0
lib/Database.php View File

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

+ 1
- 1
lib/Db/MySQL/Driver.php View File

@@ -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
- 0
sql/MySQL/4.sql View File

@@ -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
- 0
sql/PostgreSQL/4.sql View File

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

+ 2
- 2
sql/SQLite3/1.sql View File

@@ -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
- 0
sql/SQLite3/4.sql View File

@@ -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
- 0
tests/cases/Database/Base.php View File

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

+ 4
- 5
tests/cases/Database/SeriesArticle.php View File

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

+ 27
- 3
tests/cases/Database/SeriesCleanup.php View File

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

+ 2
- 3
tests/cases/Database/SeriesFeed.php View File

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

+ 2
- 3
tests/cases/Database/SeriesFolder.php View File

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

+ 4
- 5
tests/cases/Database/SeriesLabel.php View File

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

+ 2
- 3
tests/cases/Database/SeriesSession.php View File

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

+ 2
- 3
tests/cases/Database/SeriesSubscription.php View File

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

+ 4
- 5
tests/cases/Database/SeriesTag.php View File

@@ -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
- 0
tests/cases/Database/SeriesToken.php View File

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

+ 5
- 7
tests/cases/Database/SeriesUser.php View File

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


Loading…
Cancel
Save