Arsse/lib/User.php

224 lines
8.8 KiB
PHP

<?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;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\User\ExceptionConflict as Conflict;
use PasswordGenerator\Generator as PassGen;
class User {
public const DRIVER_NAMES = [
'internal' => \JKingWeb\Arsse\User\Internal\Driver::class,
];
public const PROPERTIES = [
'admin' => V::T_BOOL,
'lang' => V::T_STRING,
'tz' => V::T_STRING,
'root_folder_name' => V::T_STRING,
'sort_asc' => V::T_BOOL,
'theme' => V::T_STRING,
'page_size' => V::T_INT, // greater than zero
'shortcuts' => V::T_BOOL,
'gestures' => V::T_BOOL,
'reading_time' => V::T_BOOL,
'stylesheet' => V::T_STRING,
];
public const PROPERTIES_LARGE = ["stylesheet"];
public $id = null;
/** @var User\Driver */
protected $u;
public function __construct(\JKingWeb\Arsse\User\Driver $driver = null) {
$this->u = $driver ?? new Arsse::$conf->userDriver;
}
public function __toString() {
return (string) $this->id;
}
public function begin(): Db\Transaction {
/* TODO: A proper implementation of this would return a meta-transaction
object which would contain both a user-manager transaction (when
applicable) and a database transaction, and commit or roll back both
as the situation calls.
In theory, an external user driver would probably have to implement its
own approximation of atomic transactions and rollback. In practice the
only driver is the internal one, which is always backed by an ACID
database; the added complexity is thus being deferred until such time
as it is actually needed for a concrete implementation.
*/
return Arsse::$db->begin();
}
public function auth(string $user, string $password): bool {
$prevUser = $this->id;
$this->id = $user;
if (Arsse::$conf->userPreAuth) {
$out = true;
} else {
$out = $this->u->auth($user, $password);
}
// if authentication was successful and we don't have the user in the internal database, add it
// users must be in the internal database to preserve referential integrity
if ($out && !Arsse::$db->userExists($user)) {
Arsse::$db->userAdd($user, $password);
}
$this->id = $prevUser;
return $out;
}
public function list(): array {
return $this->u->userList();
}
public function lookup(int $num): string {
// the user number is always stored in the internal database, so the user driver is not called here
return Arsse::$db->userLookup($num);
}
public function add(string $user, ?string $password = null): string {
// ensure the user name does not contain any U+003A COLON or control characters, as
// this is incompatible with HTTP Basic authentication
if (preg_match("/[\x{00}-\x{1F}\x{7F}:]/", $user, $m)) {
$c = ord($m[0]);
throw new User\ExceptionInput("invalidUsername", "U+".str_pad((string) $c, 4, "0", \STR_PAD_LEFT)." ".\IntlChar::charName($c, \IntlChar::EXTENDED_CHAR_NAME));
}
try {
$out = $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
} catch (Conflict $e) {
if (!Arsse::$db->userExists($user)) {
Arsse::$db->userAdd($user, null);
}
throw $e;
}
// synchronize the internal database
if (!Arsse::$db->userExists($user)) {
Arsse::$db->userAdd($user, $out);
}
return $out;
}
public function rename(string $user, string $newName): bool {
// ensure the new user name does not contain any U+003A COLON or
// control characters, as this is incompatible with HTTP Basic authentication
if (preg_match("/[\x{00}-\x{1F}\x{7F}:]/", $newName, $m)) {
$c = ord($m[0]);
throw new User\ExceptionInput("invalidUsername", "U+".str_pad((string) $c, 4, "0", \STR_PAD_LEFT)." ".\IntlChar::charName($c, \IntlChar::EXTENDED_CHAR_NAME));
}
if ($this->u->userRename($user, $newName)) {
$tr = Arsse::$db->begin();
if (!Arsse::$db->userExists($user)) {
Arsse::$db->userAdd($newName, null);
} else {
Arsse::$db->userRename($user, $newName);
// invalidate any sessions and Fever passwords
Arsse::$db->sessionDestroy($newName);
Arsse::$db->tokenRevoke($newName, "fever.login");
}
$tr->commit();
return true;
}
return false;
}
public function remove(string $user): bool {
try {
$out = $this->u->userRemove($user);
} catch (Conflict $e) {
if (Arsse::$db->userExists($user)) {
Arsse::$db->userRemove($user);
}
throw $e;
}
if (Arsse::$db->userExists($user)) {
// if the user was removed and we (still) have it in the internal database, remove it there
Arsse::$db->userRemove($user);
}
return $out;
}
public function passwordSet(string $user, ?string $newPassword, $oldPassword = null): string {
$out = $this->u->userPasswordSet($user, $newPassword, $oldPassword) ?? $this->u->userPasswordSet($user, $this->generatePassword(), $oldPassword);
if (Arsse::$db->userExists($user)) {
// if the password change was successful and the user exists, set the internal password to the same value
Arsse::$db->userPasswordSet($user, $out);
// also invalidate any current sessions for the user
Arsse::$db->sessionDestroy($user);
} else {
// if the user does not exist, add it with the new password
Arsse::$db->userAdd($user, $out);
}
return $out;
}
public function passwordUnset(string $user, $oldPassword = null): bool {
$out = $this->u->userPasswordUnset($user, $oldPassword);
if (Arsse::$db->userExists($user)) {
// if the password change was successful and the user exists, set the internal password to the same value
Arsse::$db->userPasswordSet($user, null);
// also invalidate any current sessions for the user
Arsse::$db->sessionDestroy($user);
}
return $out;
}
public function generatePassword(): string {
return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
public function propertiesGet(string $user, bool $includeLarge = true): array {
$extra = $this->u->userPropertiesGet($user, $includeLarge);
// synchronize the internal database
if (!Arsse::$db->userExists($user)) {
Arsse::$db->userAdd($user, null);
Arsse::$db->userPropertiesSet($user, $extra);
}
// retrieve from the database to get at least the user number, and anything else the driver does not provide
$meta = Arsse::$db->userPropertiesGet($user, $includeLarge);
// combine all the data
$out = ['num' => $meta['num']];
foreach (self::PROPERTIES as $k => $t) {
if (array_key_exists($k, $extra)) {
$v = $extra[$k];
} elseif (array_key_exists($k, $meta)) {
$v = $meta[$k];
} else {
$v = null;
}
$out[$k] = V::normalize($v, $t | V::M_NULL);
}
return $out;
}
public function propertiesSet(string $user, array $data): array {
$in = [];
foreach (self::PROPERTIES as $k => $t) {
if (array_key_exists($k, $data)) {
try {
$in[$k] = V::normalize($data[$k], $t | V::M_NULL | V::M_STRICT);
} catch (\JKingWeb\Arsse\ExceptionType $e) {
throw new User\ExceptionInput("invalidValue", ['field' => $k, 'type' => $t], $e);
}
}
}
if (isset($in['tz']) && !@timezone_open($in['tz'])) {
throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => $in['tz']]);
} elseif (isset($in['page_size']) && $in['page_size'] < 1) {
throw new User\ExceptionInput("invalidNonZeroInteger", ['field' => "page_size"]);
}
$out = $this->u->userPropertiesSet($user, $in);
// synchronize the internal database
if (!Arsse::$db->userExists($user)) {
Arsse::$db->userAdd($user, null);
}
Arsse::$db->userPropertiesSet($user, $out);
return $out;
}
}