diff --git a/composer.json b/composer.json index 9d98159b..552c15d2 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,9 @@ "ext-hash": "*", "fguillot/picofeed": ">=0.1.31", "hosteurope/password-generator": "^1.0", - "docopt/docopt": "^1.0" + "docopt/docopt": "^1.0", + "jkingweb/druuid": "^3.0", + "phpseclib/phpseclib": "^2.0" }, "require-dev": { "mikey179/vfsStream": "^1.6", diff --git a/composer.lock b/composer.lock index ca0b7489..844cdb51 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "125797db6f29f530c2f89209cc4f462d", + "content-hash": "d00fd63e825db5ce16878c1639f362f3", "packages": [ { "name": "docopt/docopt", @@ -145,6 +145,143 @@ "description": "Password generator for generating policy-compliant passwords.", "time": "2016-12-08T09:32:12+00:00" }, + { + "name": "jkingweb/druuid", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/JKingweb/DrUUID.git", + "reference": "ca88019069f03ee9c0b1bb6b0200f421bbc9607e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JKingweb/DrUUID/zipball/ca88019069f03ee9c0b1bb6b0200f421bbc9607e", + "reference": "ca88019069f03ee9c0b1bb6b0200f421bbc9607e", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "suggest": { + "ext-bcmath": "Supported alternative to GMP on 32-bit systems", + "ext-gmp": "Recommended on 32-bit installations for time-base UUIDs", + "phpseclib/phpseclib": "Supported alternative to GMP or BC Math on 32-bit systems (either v1.x or v2.x)" + }, + "type": "library", + "autoload": { + "psr-4": { + "JKingWeb\\DrUUID\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "J. King", + "email": "jking@jkingweb.ca", + "homepage": "https://jkingweb.ca/" + } + ], + "description": "DrUUID RFC 4122 library for PHP", + "keywords": [ + "uuid" + ], + "time": "2017-02-09T14:17:01+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "2.0.6", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "34a7699e6f31b1ef4035ee36444407cecf9f56aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/34a7699e6f31b1ef4035ee36444407cecf9f56aa", + "reference": "34a7699e6f31b1ef4035ee36444407cecf9f56aa", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phing/phing": "~2.7", + "phpunit/phpunit": "~4.0", + "sami/sami": "~2.0", + "squizlabs/php_codesniffer": "~2.0" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "time": "2017-06-05T06:31:10+00:00" + }, { "name": "zendframework/zendxml", "version": "1.0.2", @@ -310,6 +447,68 @@ ], "time": "2012-12-19T10:50:58+00:00" }, + { + "name": "composer/semver", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/c7cb9a2095a074d131b65a8a0cd294479d785573", + "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.5 || ^5.0.5", + "phpunit/phpunit-mock-objects": "2.3.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "time": "2016-08-30T16:08:34+00:00" + }, { "name": "container-interop/container-interop", "version": "1.2.0", @@ -561,19 +760,20 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.2.6", + "version": "v2.2.7", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "c1cc52c242f17c4d52d9601159631da488fac7a4" + "reference": "b6202ccad4c00778887e7e8282d52f854802b59a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/c1cc52c242f17c4d52d9601159631da488fac7a4", - "reference": "c1cc52c242f17c4d52d9601159631da488fac7a4", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/b6202ccad4c00778887e7e8282d52f854802b59a", + "reference": "b6202ccad4c00778887e7e8282d52f854802b59a", "shasum": "" }, "require": { + "composer/semver": "^1.4", "doctrine/annotations": "^1.2", "ext-json": "*", "ext-tokenizer": "*", @@ -641,7 +841,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2017-08-22T14:08:16+00:00" + "time": "2017-09-11T14:27:07+00:00" }, { "name": "gecko-packages/gecko-php-unit", @@ -1599,16 +1799,16 @@ }, { "name": "phake/phake", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/mlively/Phake.git", - "reference": "c242d6a8376bd3280d903d95725d3e1e2f9efadc" + "reference": "949340efc3cd99b401a0dd1a5ffeac690a3c3967" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mlively/Phake/zipball/c242d6a8376bd3280d903d95725d3e1e2f9efadc", - "reference": "c242d6a8376bd3280d903d95725d3e1e2f9efadc", + "url": "https://api.github.com/repos/mlively/Phake/zipball/949340efc3cd99b401a0dd1a5ffeac690a3c3967", + "reference": "949340efc3cd99b401a0dd1a5ffeac690a3c3967", "shasum": "" }, "require": { @@ -1653,7 +1853,7 @@ "mock", "testing" ], - "time": "2017-07-04T20:09:48+00:00" + "time": "2017-09-06T12:09:44+00:00" }, { "name": "phar-io/manifest", @@ -2226,22 +2426,22 @@ }, { "name": "phpspec/prophecy", - "version": "v1.7.0", + "version": "v1.7.2", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "93d39f1f7f9326d746203c7c056f300f7f126073" + "reference": "c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/93d39f1f7f9326d746203c7c056f300f7f126073", - "reference": "93d39f1f7f9326d746203c7c056f300f7f126073", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6", + "reference": "c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6", "shasum": "" }, "require": { "doctrine/instantiator": "^1.0.2", "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "^2.0|^3.0.2", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0", "sebastian/comparator": "^1.1|^2.0", "sebastian/recursion-context": "^1.0|^2.0|^3.0" }, @@ -2252,7 +2452,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.6.x-dev" + "dev-master": "1.7.x-dev" } }, "autoload": { @@ -2285,7 +2485,7 @@ "spy", "stub" ], - "time": "2017-03-02T20:05:34+00:00" + "time": "2017-09-04T11:05:03+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3764,7 +3964,7 @@ }, { "name": "symfony/options-resolver", - "version": "v3.3.8", + "version": "v3.3.9", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", @@ -4340,7 +4540,7 @@ }, { "name": "symfony/yaml", - "version": "v3.3.8", + "version": "v3.3.9", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 034254ca..1ac5a1d9 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -56,6 +56,7 @@ abstract class AbstractException extends \Exception { "User/Exception.authMissing" => 10411, "User/Exception.authFailed" => 10412, "User/ExceptionAuthz.notAuthorized" => 10421, + "User/ExceptionSession.invalid" => 10431, "Feed/Exception.invalidCertificate" => 10501, "Feed/Exception.invalidUrl" => 10502, "Feed/Exception.maxRedirect" => 10503, diff --git a/lib/Conf.php b/lib/Conf.php index a995a949..7ed80d6c 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -28,10 +28,16 @@ class Conf { public $userPreAuth = false; /** @var integer Desired length of temporary user passwords */ public $userTempPasswordLength = 20; + /** @var string Period of inactivity after which log-in sessions should be considered invalid, as an ISO 8601 duration (default: 1 hour) + * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ + public $userSessionTimeout = "PT1H"; + /** @var string Maximum lifetime of log-in sessions regardless of activity, as an ISO 8601 duration (default: 24 hours); + * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ + public $userSessionLifetime = "PT24H"; /** @var string Class of the background feed update service driver in use (Forking by default) */ public $serviceDriver = Service\Forking\Driver::class; - /** @var string The interval between checks for new feeds, as an ISO 8601 duration + /** @var string The interval between checks for new articles, as an ISO 8601 duration * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $serviceFrequency = "PT2M"; /** @var integer Number of concurrent feed updates to perform */ diff --git a/lib/Database.php b/lib/Database.php index 4983da39..ddce4f1e 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; use PasswordGenerator\Generator as PassGen; +use JKingWeb\DrUUID\UUID; use JKingWeb\Arsse\Misc\Query; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Date; @@ -223,6 +224,61 @@ class Database { return true; } + public function sessionCreate(string $user): 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 new session ID and expiry date + $id = UUID::mint()->hex; + $expires = Date::add(Arsse::$conf->userSessionTimeout); + // save the session to the database + $this->db->prepare("INSERT INTO arsse_sessions(id,expires,user) values(?,?,?)", "str", "datetime", "str")->run($id, $expires, $user); + // return the ID + return $id; + } + + public function sessionDestroy(string $user, string $id): 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]); + } + // delete the session and report success. + return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id is ? and user is ?", "str", "str")->run($id, $user)->changes(); + } + + public function sessionResume(string $id): array { + $maxage = Date::sub(Arsse::$conf->userSessionLifetime); + $out = $this->db->prepare("SELECT * from arsse_sessions where id is ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxage)->getRow(); + // if the session does not exist or is expired, throw an exception + if (!$out) { + throw new User\ExceptionSession("invalid", $id); + } + // otherwise populate the session user when appropriate + if (Arsse::$user) { + Arsse::$user->id = $out['user']; + } + // if we're more than half-way from the session expiring, renew it + if ($this->sessionExpiringSoon(Date::normalize($out['expires'], "sql"))) { + $expires = Date::add(Arsse::$conf->userSessionTimeout); + $this->db->prepare("UPDATE arsse_sessions set expires = ? where id is ?", "datetime", "str")->run($expires, $id); + } + return $out; + } + + public function sessionCleanup(): int { + return $this->db->query("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP")->changes(); + } + + protected function sessionExpiringSoon(DateTimeInterface $expiry): bool { + // calculate half the session timeout as a number of seconds + $now = time(); + $max = Date::add(Arsse::$conf->userSessionTimeout, $now)->getTimestamp(); + $diff = intdiv($max - $now, 2); + // determine if the expiry time is less than half the session timeout into the future + return (($now + $diff) >= $expiry->getTimestamp()); + } + public function folderAdd(string $user, array $data): int { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { diff --git a/lib/Service.php b/lib/Service.php index baffb329..f1ce2353 100644 --- a/lib/Service.php +++ b/lib/Service.php @@ -84,7 +84,10 @@ class Service { public static function cleanupPre(): bool { // mark unsubscribed feeds as orphaned and delete orphaned feeds that are beyond their retention period - return Arsse::$db->feedCleanup(); + Arsse::$db->feedCleanup(); + // delete expired log-in sessions + Arsse::$db->sessionCleanup(); + return true; } public static function cleanupPost(): bool { diff --git a/lib/User/ExceptionSession.php b/lib/User/ExceptionSession.php new file mode 100644 index 00000000..0f931032 --- /dev/null +++ b/lib/User/ExceptionSession.php @@ -0,0 +1,6 @@ + 'Session with ID {0} does not exist', 'Exception.JKingWeb/Arsse/Feed/Exception.invalidCertificate' => 'Could not download feed "{url}" because its server is serving an invalid SSL certificate', 'Exception.JKingWeb/Arsse/Feed/Exception.invalidUrl' => 'Feed URL "{url}" is invalid', 'Exception.JKingWeb/Arsse/Feed/Exception.maxRedirect' => 'Could not download feed "{url}" because its server reached its maximum number of HTTP redirections', diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql new file mode 100644 index 00000000..ae97f071 --- /dev/null +++ b/sql/SQLite3/1.sql @@ -0,0 +1,29 @@ +-- Sessions for Tiny Tiny RSS (and possibly others) +create table arsse_sessions ( + id text primary key, -- UUID of session + created datetime not null default CURRENT_TIMESTAMP, -- Session start timestamp + expires datetime 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; + +-- User-defined article labels for Tiny Tiny RSS +create table arsse_labels ( + id integer primary key, -- numeric ID + owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user + name text not null, -- label text + foreground text, -- foreground (text) colour in hexdecimal RGB + background text, -- background colour in hexadecimal RGB + unique(owner,name) +); + +-- Labels assignments for articles +create table arsse_label_members ( + label integer not null references arsse_labels(id) on delete cascade, + article integer not null references arsse_articles(id) on delete cascade, + subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription is included so that records are deleted when a subscription is removed + primary key(label,article) +) without rowid; + +-- set version marker +pragma user_version = 2; +insert into arsse_meta(key,value) values('schema_version','2'); \ No newline at end of file