Files
daydream/lib/User.php
T
2026-09-04 01:21:19 +02:00

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