0, 'name' => '', 'email' => '', 'language' => '', 'timezone' => '', '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, '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'], 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(!$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 ":"; 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'); } }