WebOrbiton
v1.0.0.9

Publisium

382 lines · 13.0 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. ​
  7. final class UserAuth
  8. {
  9. private const REMEMBER_COOKIE = 'publisium_remember';
  10. private const REMEMBER_DAYS = 365;
  11. private const REMEMBER_CHECK_SECONDS = 3600;
  12. private const REMEMBER_GRACE_SECONDS = 600;
  13. ​
  14. private static ?array $current = null;
  15. private static bool $rememberTableReady = false;
  16. ​
  17. public static function boot(): void
  18. {
  19. Config::startSession();
  20. ​
  21. try {
  22. self::syncRememberToken();
  23. } catch (PDOException $e) {
  24. error_log('Publisium: reader remember token check failed: ' . $e->getMessage());
  25. }
  26. }
  27. ​
  28. public static function registrationEnabled(): bool
  29. {
  30. require_once __DIR__ . '/site-front.php';
  31. ​
  32. return SiteFront::settings()['registration_enabled'] === '1';
  33. }
  34. ​
  35. public static function register(string $email, string $password, string $displayName): array
  36. {
  37. if (!self::registrationEnabled()) {
  38. return [false, Language::get('auth_registration_closed', 'Sign-ups are closed at the moment.')];
  39. }
  40. ​
  41. $email = trim(strtolower($email));
  42. ​
  43. if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
  44. return [false, Language::get('auth_invalid_email', 'Please enter a valid email address.')];
  45. }
  46. ​
  47. if (strlen($password) < 8) {
  48. return [false, Language::get('auth_password_too_short', 'Your password needs at least 8 characters.')];
  49. }
  50. ​
  51. $db = Database::users();
  52. ​
  53. $existingStatement = $db->prepare('SELECT id FROM user_accounts WHERE email = :email LIMIT 1');
  54. $existingStatement->execute(['email' => $email]);
  55. if ($existingStatement->fetch()) {
  56. return [false, Language::get('auth_email_taken', 'There’s already an account with this email. Try logging in instead.')];
  57. }
  58. ​
  59. $insert = $db->prepare(
  60. 'INSERT INTO user_accounts (email, password_hash, display_name, status) VALUES (:email, :hash, :display_name, :status)'
  61. );
  62. $passwordHash = password_hash($password, PASSWORD_DEFAULT);
  63. $insert->execute([
  64. 'email' => $email,
  65. 'hash' => $passwordHash,
  66. 'display_name' => $displayName !== '' ? $displayName : explode('@', $email)[0],
  67. 'status' => 'active',
  68. ]);
  69. ​
  70. $newId = (int) $db->lastInsertId();
  71. self::startSession($newId, $passwordHash);
  72. ​
  73. return [true, null];
  74. }
  75. ​
  76. public static function attemptLogin(string $email, string $password): bool
  77. {
  78. $email = trim(strtolower($email));
  79. $db = Database::users();
  80. ​
  81. $statement = $db->prepare('SELECT * FROM user_accounts WHERE email = :email AND status = :status LIMIT 1');
  82. $statement->execute(['email' => $email, 'status' => 'active']);
  83. $account = $statement->fetch();
  84. ​
  85. if (!$account || !password_verify($password, $account['password_hash'])) {
  86. return false;
  87. }
  88. ​
  89. self::startSession((int) $account['id'], (string) $account['password_hash']);
  90. ​
  91. $update = $db->prepare('UPDATE user_accounts SET last_login_at = NOW() WHERE id = :id');
  92. $update->execute(['id' => $account['id']]);
  93. ​
  94. return true;
  95. }
  96. ​
  97. private static function startSession(int $userId, string $passwordHash): void
  98. {
  99. session_regenerate_id(true);
  100. $_SESSION['user_account_id'] = $userId;
  101. $_SESSION['user_password_fp'] = self::passwordFingerprint($passwordHash);
  102. self::$current = null;
  103. ​
  104. try {
  105. self::revokeCurrentRememberToken();
  106. self::issueRememberToken($userId);
  107. $_SESSION['user_remember_checked'] = time();
  108. } catch (PDOException $e) {
  109. error_log('Publisium: could not issue reader remember token: ' . $e->getMessage());
  110. }
  111. }
  112. ​
  113. public static function logout(): void
  114. {
  115. self::revokeCurrentRememberToken();
  116. self::clearRememberCookie();
  117. ​
  118. unset($_SESSION['user_account_id'], $_SESSION['user_password_fp'], $_SESSION['user_remember_checked']);
  119. self::$current = null;
  120. ​
  121. if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
  122. session_regenerate_id(true);
  123. }
  124. }
  125. ​
  126. public static function passwordChanged(int $userId, string $newPasswordHash): void
  127. {
  128. self::revokeAllRememberTokens($userId);
  129. self::startSession($userId, $newPasswordHash);
  130. }
  131. ​
  132. public static function revokeAllRememberTokens(int $userId): void
  133. {
  134. try {
  135. $statement = Database::users()->prepare('DELETE FROM user_remember_tokens WHERE user_account_id = :id');
  136. $statement->execute(['id' => $userId]);
  137. } catch (PDOException $e) {
  138. error_log('Publisium: could not revoke reader remember tokens: ' . $e->getMessage());
  139. }
  140. }
  141. ​
  142. private static function syncRememberToken(): void
  143. {
  144. if (headers_sent()) {
  145. return;
  146. }
  147. ​
  148. if (self::check() && (int) ($_SESSION['user_remember_checked'] ?? 0) > time() - self::REMEMBER_CHECK_SECONDS) {
  149. return;
  150. }
  151. ​
  152. $token = self::findRememberToken();
  153. ​
  154. if (!self::check()) {
  155. if ($token === null) {
  156. if (isset($_COOKIE[self::REMEMBER_COOKIE])) {
  157. self::clearRememberCookie();
  158. }
  159. return;
  160. }
  161. ​
  162. session_regenerate_id(true);
  163. $_SESSION['user_account_id'] = (int) $token['user_account_id'];
  164. $_SESSION['user_password_fp'] = self::passwordFingerprint((string) $token['password_hash']);
  165. $_SESSION['user_remember_checked'] = 0;
  166. }
  167. ​
  168. $userId = (int) $_SESSION['user_account_id'];
  169. ​
  170. if ($token !== null && (int) $token['user_account_id'] === $userId) {
  171. if ($token['rotated_at'] !== null) {
  172. return;
  173. }
  174. if ((int) $token['rotation_due'] === 1 && !self::rotateRememberToken((int) $token['id'], $userId)) {
  175. return;
  176. }
  177. $_SESSION['user_remember_checked'] = time();
  178. return;
  179. }
  180. ​
  181. if (self::user() === null) {
  182. return;
  183. }
  184. self::revokeCurrentRememberToken();
  185. self::issueRememberToken($userId);
  186. $_SESSION['user_remember_checked'] = time();
  187. }
  188. ​
  189. private static function findRememberToken(): ?array
  190. {
  191. $raw = $_COOKIE[self::REMEMBER_COOKIE] ?? null;
  192. if (!is_string($raw) || preg_match('/^[a-f0-9]{64}$/', $raw) !== 1) {
  193. return null;
  194. }
  195. ​
  196. try {
  197. $statement = Database::users()->prepare(sprintf(
  198. "SELECT t.id, t.user_account_id, t.rotated_at, (t.created_at < NOW() - INTERVAL 1 DAY) AS rotation_due, a.password_hash
  199. FROM user_remember_tokens t
  200. JOIN user_accounts a ON a.id = t.user_account_id AND a.status = 'active'
  201. WHERE t.token_hash = :hash AND t.expires_at > NOW()
  202. AND (t.rotated_at IS NULL OR t.rotated_at > NOW() - INTERVAL %d SECOND)
  203. LIMIT 1",
  204. self::REMEMBER_GRACE_SECONDS
  205. ));
  206. $statement->execute(['hash' => hash('sha256', $raw)]);
  207. $token = $statement->fetch();
  208. } catch (PDOException $e) {
  209. return null;
  210. }
  211. ​
  212. return $token ?: null;
  213. }
  214. ​
  215. private static function rotateRememberToken(int $tokenId, int $userId): bool
  216. {
  217. $statement = Database::users()->prepare(
  218. 'UPDATE user_remember_tokens SET rotated_at = NOW() WHERE id = :id AND rotated_at IS NULL'
  219. );
  220. $statement->execute(['id' => $tokenId]);
  221. ​
  222. if ($statement->rowCount() === 0) {
  223. return false;
  224. }
  225. ​
  226. self::issueRememberToken($userId);
  227. return true;
  228. }
  229. ​
  230. private static function issueRememberToken(int $userId): void
  231. {
  232. self::ensureRememberTable();
  233. $db = Database::users();
  234. ​
  235. $db->exec('DELETE FROM user_remember_tokens WHERE expires_at < NOW() OR rotated_at < NOW() - INTERVAL 1 DAY');
  236. ​
  237. $raw = bin2hex(random_bytes(32));
  238. $insert = $db->prepare(sprintf(
  239. 'INSERT INTO user_remember_tokens (user_account_id, token_hash, expires_at, ip_address, user_agent)
  240. VALUES (:user_id, :hash, NOW() + INTERVAL %d DAY, :ip, :user_agent)',
  241. self::REMEMBER_DAYS
  242. ));
  243. $insert->execute([
  244. 'user_id' => $userId,
  245. 'hash' => hash('sha256', $raw),
  246. 'ip' => substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45),
  247. 'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
  248. ]);
  249. ​
  250. self::setRememberCookie($raw, time() + self::REMEMBER_DAYS * 86400);
  251. }
  252. ​
  253. private static function revokeCurrentRememberToken(): void
  254. {
  255. $raw = $_COOKIE[self::REMEMBER_COOKIE] ?? null;
  256. if (!is_string($raw) || preg_match('/^[a-f0-9]{64}$/', $raw) !== 1) {
  257. return;
  258. }
  259. ​
  260. try {
  261. $statement = Database::users()->prepare('DELETE FROM user_remember_tokens WHERE token_hash = :hash');
  262. $statement->execute(['hash' => hash('sha256', $raw)]);
  263. } catch (PDOException $e) {
  264. }
  265. }
  266. ​
  267. private static function setRememberCookie(string $value, int $expires): void
  268. {
  269. if (!headers_sent()) {
  270. header('Cache-Control: private, no-store');
  271. setcookie(self::REMEMBER_COOKIE, $value, [
  272. 'expires' => $expires,
  273. 'path' => '/',
  274. 'secure' => Config::cookieSecure(),
  275. 'httponly' => true,
  276. 'samesite' => 'Lax',
  277. ]);
  278. }
  279. $_COOKIE[self::REMEMBER_COOKIE] = $value;
  280. }
  281. ​
  282. private static function clearRememberCookie(): void
  283. {
  284. if (isset($_COOKIE[self::REMEMBER_COOKIE]) && !headers_sent()) {
  285. header('Cache-Control: private, no-store');
  286. setcookie(self::REMEMBER_COOKIE, '', [
  287. 'expires' => time() - 3600,
  288. 'path' => '/',
  289. 'secure' => Config::cookieSecure(),
  290. 'httponly' => true,
  291. 'samesite' => 'Lax',
  292. ]);
  293. }
  294. unset($_COOKIE[self::REMEMBER_COOKIE]);
  295. }
  296. ​
  297. private static function ensureRememberTable(): void
  298. {
  299. if (self::$rememberTableReady) {
  300. return;
  301. }
  302. ​
  303. Database::users()->exec(
  304. 'CREATE TABLE IF NOT EXISTS user_remember_tokens (
  305. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  306. user_account_id INT UNSIGNED NOT NULL,
  307. token_hash CHAR(64) NOT NULL,
  308. expires_at DATETIME NOT NULL,
  309. rotated_at DATETIME NULL,
  310. ip_address VARCHAR(45) NULL,
  311. user_agent VARCHAR(255) NULL,
  312. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  313. UNIQUE KEY uq_remember_token_hash (token_hash),
  314. KEY idx_remember_account (user_account_id),
  315. KEY idx_remember_expires (expires_at),
  316. KEY idx_remember_rotated (rotated_at),
  317. CONSTRAINT fk_remember_account FOREIGN KEY (user_account_id) REFERENCES user_accounts(id) ON DELETE CASCADE
  318. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
  319. );
  320. self::$rememberTableReady = true;
  321. }
  322. ​
  323. private static function passwordFingerprint(string $passwordHash): string
  324. {
  325. return hash('sha256', $passwordHash);
  326. }
  327. ​
  328. public static function check(): bool
  329. {
  330. return isset($_SESSION['user_account_id']);
  331. }
  332. ​
  333. public static function user(): ?array
  334. {
  335. if (!self::check()) {
  336. return null;
  337. }
  338. ​
  339. if (self::$current !== null) {
  340. return self::$current;
  341. }
  342. ​
  343. $statement = Database::users()->prepare('SELECT * FROM user_accounts WHERE id = :id LIMIT 1');
  344. $statement->execute(['id' => $_SESSION['user_account_id']]);
  345. $account = $statement->fetch();
  346. ​
  347. if (!$account || $account['status'] !== 'active') {
  348. self::logout();
  349. return null;
  350. }
  351. ​
  352. $fingerprint = self::passwordFingerprint((string) $account['password_hash']);
  353. if (!isset($_SESSION['user_password_fp'])) {
  354. $_SESSION['user_password_fp'] = $fingerprint;
  355. } elseif (!hash_equals((string) $_SESSION['user_password_fp'], $fingerprint)) {
  356. self::logout();
  357. return null;
  358. }
  359. ​
  360. self::$current = $account;
  361. return self::$current;
  362. }
  363. ​
  364. public static function hasActiveSubscription(int $userAccountId): bool
  365. {
  366. $statement = Database::users()->prepare(
  367. "SELECT id FROM subscriptions WHERE user_account_id = :id AND status IN ('active', 'trialing') AND (current_period_end IS NULL OR current_period_end > NOW()) LIMIT 1"
  368. );
  369. $statement->execute(['id' => $userAccountId]);
  370. ​
  371. return (bool) $statement->fetch();
  372. }
  373. ​
  374. public static function requireLogin(): void
  375. {
  376. if (self::user() === null) {
  377. header('Location: user-login.php');
  378. exit;
  379. }
  380. }
  381. }
  382. ​