281 lines
8.7 KiB
PHP
281 lines
8.7 KiB
PHP
<?php
|
|
|
|
namespace Franzz\MyThoughts;
|
|
|
|
use Franzz\Objects\Db;
|
|
use Franzz\Objects\PhpObject;
|
|
|
|
/**
|
|
* Accounts, sessions and the remember-me cookie.
|
|
*
|
|
* Every reader gets their own book: an entry always belongs to exactly one
|
|
* user, and nothing in Journal is reachable without a resolved user id.
|
|
*/
|
|
class User extends PhpObject {
|
|
public const USER_TABLE = 'users';
|
|
|
|
public const CLEARANCE_USER = 0;
|
|
public const CLEARANCE_ADMIN = 9;
|
|
|
|
private const MIN_PASSWORD_LENGTH = 8;
|
|
private const MAX_NAME_LENGTH = 100;
|
|
|
|
public const DEFAULT_USER = [
|
|
'id' => 0,
|
|
'name' => '',
|
|
'email' => '',
|
|
'language' => '',
|
|
'timezone' => '',
|
|
'hand' => MyThoughts::DEFAULT_HAND,
|
|
'clearance' => self::CLEARANCE_USER
|
|
];
|
|
|
|
//Session & Cookie
|
|
private const SESSION_ID_USER = 'id_user';
|
|
private const COOKIE_TOKEN = 'mythoughts';
|
|
private const COOKIE_DURATION = 60 * 60 * 24 * 90; //3 months
|
|
|
|
private Db $oDb;
|
|
private int $iUserId = 0;
|
|
private array $asUserInfo = self::DEFAULT_USER;
|
|
|
|
public function __construct(Db &$oDb) {
|
|
parent::__construct(__CLASS__);
|
|
$this->oDb = &$oDb;
|
|
$this->setUserId(0);
|
|
$this->checkSession();
|
|
}
|
|
|
|
/* Identity */
|
|
|
|
public function getUserId(): int {
|
|
return $this->iUserId;
|
|
}
|
|
|
|
public function isLoggedIn(): bool {
|
|
return ($this->iUserId > 0);
|
|
}
|
|
|
|
public function getUserInfo(): array {
|
|
return $this->asUserInfo;
|
|
}
|
|
|
|
public function getLang(): string {
|
|
return $this->asUserInfo['language'];
|
|
}
|
|
|
|
public function getTimezone(): string {
|
|
return $this->asUserInfo['timezone'];
|
|
}
|
|
|
|
public function setUserId($iUserId): void {
|
|
$this->iUserId = 0;
|
|
$this->asUserInfo = self::DEFAULT_USER;
|
|
|
|
if($iUserId > 0) {
|
|
$asUser = $this->getUserById($iUserId);
|
|
if(!empty($asUser)) {
|
|
$this->iUserId = (int) $iUserId;
|
|
$this->asUserInfo = $asUser;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function getUserById($iUserId): array {
|
|
if($iUserId <= 0) return [];
|
|
|
|
$asSelect = array_keys(self::DEFAULT_USER);
|
|
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE).' AS id';
|
|
|
|
$asUser = $this->oDb->selectRow(self::USER_TABLE, [Db::getId(self::USER_TABLE) => $iUserId], $asSelect);
|
|
if(empty($asUser)) return [];
|
|
|
|
$asUser['id'] = (int) $asUser['id'];
|
|
$asUser['clearance'] = (int) $asUser['clearance'];
|
|
return $asUser;
|
|
}
|
|
|
|
/* Sign up & log in */
|
|
|
|
public function signup(string $sName, string $sEmail, string $sPassword, string $sLang, string $sTimezone): array {
|
|
$sEmail = mb_strtolower(trim($sEmail));
|
|
$sName = mb_substr(trim($sName), 0, self::MAX_NAME_LENGTH);
|
|
|
|
if($sName === '') return MyThoughts::getResult(false, 'account.name_required');
|
|
if(!filter_var($sEmail, FILTER_VALIDATE_EMAIL)) return MyThoughts::getResult(false, 'account.invalid_email');
|
|
if(mb_strlen($sPassword) < self::MIN_PASSWORD_LENGTH) return MyThoughts::getResult(false, 'account.password_too_short', [], [self::MIN_PASSWORD_LENGTH]);
|
|
|
|
//Taken emails must not be distinguishable from a wrong password, so the
|
|
//message stays the same one login gives - no account enumeration here.
|
|
if($this->oDb->selectId(self::USER_TABLE, ['email' => $sEmail]) > 0) {
|
|
return MyThoughts::getResult(false, 'account.invalid_credentials');
|
|
}
|
|
|
|
$iUserId = $this->oDb->insertRow(self::USER_TABLE, [
|
|
'name' => $sName,
|
|
'email' => $sEmail,
|
|
'password' => password_hash($sPassword, PASSWORD_DEFAULT),
|
|
'language' => $sLang,
|
|
'timezone' => $sTimezone,
|
|
'hand' => MyThoughts::DEFAULT_HAND,
|
|
'clearance' => self::CLEARANCE_USER
|
|
]);
|
|
|
|
if($iUserId <= 0) return MyThoughts::getResult(false, 'error.commit_db');
|
|
|
|
$this->openSessionFor($iUserId, true);
|
|
return MyThoughts::getResult(true, 'account.welcome', ['user' => $this->getUserInfo()]);
|
|
}
|
|
|
|
public function login(string $sEmail, string $sPassword, string $sTimezone, bool $bRemember): array {
|
|
$sEmail = mb_strtolower(trim($sEmail));
|
|
|
|
$asDbUser = $this->oDb->selectRow(
|
|
self::USER_TABLE,
|
|
['email' => $sEmail],
|
|
[Db::getId(self::USER_TABLE), 'password', 'timezone']
|
|
);
|
|
$iUserId = (int) ($asDbUser[Db::getId(self::USER_TABLE)] ?? 0);
|
|
|
|
//password_verify against a dummy hash on unknown emails keeps the
|
|
//response time of "no such user" and "wrong password" comparable.
|
|
$sHash = $asDbUser['password'] ?? '';
|
|
$bValid = ($sHash !== '') ? password_verify($sPassword, $sHash) : password_verify($sPassword, '$2y$10$'.str_repeat('.', 53));
|
|
|
|
if($iUserId <= 0 || !$bValid) return MyThoughts::getResult(false, 'account.invalid_credentials');
|
|
|
|
if($sTimezone !== '' && $sTimezone !== ($asDbUser['timezone'] ?? '')) {
|
|
$this->oDb->updateRow(self::USER_TABLE, $iUserId, ['timezone' => $sTimezone]);
|
|
}
|
|
|
|
$this->openSessionFor($iUserId, $bRemember);
|
|
return MyThoughts::getResult(true, 'account.logged_in', ['user' => $this->getUserInfo()]);
|
|
}
|
|
|
|
public function logout(): array {
|
|
$this->clearTokenCookie();
|
|
|
|
$_SESSION = [];
|
|
if(session_status() === PHP_SESSION_ACTIVE) session_regenerate_id(true);
|
|
|
|
$this->setUserId(0);
|
|
return MyThoughts::getResult(true, 'account.logged_out');
|
|
}
|
|
|
|
public function updateSettings(string $sField, string $sValue): array {
|
|
if(!$this->isLoggedIn()) return MyThoughts::getResult(false, MyThoughts::UNAUTHORIZED);
|
|
if(!in_array($sField, ['name', 'language', 'timezone', 'hand'], true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
|
|
|
$sValue = mb_substr(trim($sValue), 0, self::MAX_NAME_LENGTH);
|
|
if($sField === 'name' && $sValue === '') return MyThoughts::getResult(false, 'account.name_required');
|
|
if($sField === 'timezone' && !in_array($sValue, \DateTimeZone::listIdentifiers(), true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
|
if($sField === 'hand' && !in_array($sValue, MyThoughts::getHandIds(), true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
|
|
|
if(!$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, [$sField => $sValue])) {
|
|
return MyThoughts::getResult(false, 'error.commit_db');
|
|
}
|
|
|
|
$this->setUserId($this->iUserId);
|
|
return MyThoughts::getResult(true, 'account.saved', ['user' => $this->getUserInfo()]);
|
|
}
|
|
|
|
/* Session plumbing */
|
|
|
|
private function openSessionFor(int $iUserId, bool $bRemember): void {
|
|
$this->setUserId($iUserId);
|
|
|
|
if(session_status() === PHP_SESSION_ACTIVE) {
|
|
session_regenerate_id(true);
|
|
$_SESSION[self::SESSION_ID_USER] = $iUserId;
|
|
}
|
|
|
|
if($bRemember) $this->setTokenCookie();
|
|
else $this->clearTokenCookie();
|
|
}
|
|
|
|
private function checkSession(): void {
|
|
$iUserId = (int) ($_SESSION[self::SESSION_ID_USER] ?? 0);
|
|
if($iUserId > 0) $this->setUserId($iUserId);
|
|
else $this->checkTokenCookie();
|
|
}
|
|
|
|
/**
|
|
* Cookie holds "<id_user>:<secret>"; only a hash of the secret is stored,
|
|
* so a dump of the users table cannot be replayed as a login.
|
|
*/
|
|
private function checkTokenCookie(): void {
|
|
$sCookie = $_COOKIE[self::COOKIE_TOKEN] ?? '';
|
|
if($sCookie === '') return;
|
|
|
|
$asParts = explode(':', $sCookie, 2);
|
|
if(count($asParts) != 2) return;
|
|
|
|
$iUserId = (int) $asParts[0];
|
|
$sSecret = $asParts[1];
|
|
if($iUserId <= 0 || $sSecret === '') return;
|
|
|
|
$asToken = $this->oDb->selectRow(self::USER_TABLE, $iUserId, ['token', 'token_exp']);
|
|
$sStoredHash = $asToken['token'] ?? '';
|
|
|
|
if($sStoredHash === '' || strtotime($asToken['token_exp'] ?? '0') < time()) {
|
|
$this->clearTokenCookie();
|
|
return;
|
|
}
|
|
|
|
if(!hash_equals($sStoredHash, hash('sha256', $sSecret))) {
|
|
$this->clearTokenCookie();
|
|
return;
|
|
}
|
|
|
|
$this->setUserId($iUserId);
|
|
if(!$this->isLoggedIn()) {
|
|
$this->clearTokenCookie();
|
|
return;
|
|
}
|
|
|
|
if(session_status() === PHP_SESSION_ACTIVE) $_SESSION[self::SESSION_ID_USER] = $iUserId;
|
|
|
|
//Sliding expiry: an active reader is never logged out mid-journal.
|
|
$this->setTokenCookie();
|
|
}
|
|
|
|
private function setTokenCookie(): void {
|
|
if(!$this->isLoggedIn()) return;
|
|
|
|
$sSecret = bin2hex(random_bytes(32));
|
|
$iExpiry = time() + self::COOKIE_DURATION;
|
|
|
|
$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, [
|
|
'token' => hash('sha256', $sSecret),
|
|
'token_exp' => date(Db::TIMESTAMP_FORMAT, $iExpiry)
|
|
]);
|
|
|
|
$this->writeCookie($this->iUserId.':'.$sSecret, $iExpiry);
|
|
}
|
|
|
|
private function clearTokenCookie(): void {
|
|
if($this->isLoggedIn()) {
|
|
$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, ['token' => '', 'token_exp' => MyThoughts::ZERO_TIMESTAMP]);
|
|
}
|
|
|
|
$this->writeCookie('', time() - 3600);
|
|
unset($_COOKIE[self::COOKIE_TOKEN]);
|
|
}
|
|
|
|
private function writeCookie(string $sValue, int $iExpiry): void {
|
|
if(PHP_SAPI === 'cli' || headers_sent()) return;
|
|
|
|
setcookie(self::COOKIE_TOKEN, $sValue, [
|
|
'expires' => $iExpiry,
|
|
'path' => dirname($_SERVER['SCRIPT_NAME'] ?? '/'),
|
|
'httponly' => true,
|
|
'secure' => self::isSecureRequest(),
|
|
'samesite' => 'Lax'
|
|
]);
|
|
}
|
|
|
|
public static function isSecureRequest(): bool {
|
|
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
|
}
|
|
}
|