v2.0.0.0
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/database.php';
-
- final class UserAuth
- {
- private const REMEMBER_COOKIE = 'publisium_remember';
- private const REMEMBER_DAYS = 365;
- private const REMEMBER_CHECK_SECONDS = 3600;
- private const REMEMBER_GRACE_SECONDS = 600;
-
- private static ?array $current = null;
- private static bool $rememberTableReady = false;
-
- public static function boot(): void
- {
- Config::startSession();
-
- try {
- self::syncRememberToken();
- } catch (PDOException $e) {
- error_log('Publisium: reader remember token check failed: ' . $e->getMessage());
- }
- }
-
- public static function registrationEnabled(): bool
- {
- require_once __DIR__ . '/site-front.php';
-
- return SiteFront::settings()['registration_enabled'] === '1';
- }
-
- public static function register(string $email, string $password, string $displayName): array
- {
- if (!self::registrationEnabled()) {
- return [false, Language::get('auth_registration_closed', 'Sign-ups are closed at the moment.')];
- }
-
- $email = trim(strtolower($email));
-
- if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
- return [false, Language::get('auth_invalid_email', 'Please enter a valid email address.')];
- }
-
- if (strlen($password) < 8) {
- return [false, Language::get('auth_password_too_short', 'Your password needs at least 8 characters.')];
- }
-
- $db = Database::users();
-
- $existingStatement = $db->prepare('SELECT id FROM user_accounts WHERE email = :email LIMIT 1');
- $existingStatement->execute(['email' => $email]);
- if ($existingStatement->fetch()) {
- return [false, Language::get('auth_email_taken', 'There’s already an account with this email. Try logging in instead.')];
- }
-
- $insert = $db->prepare(
- 'INSERT INTO user_accounts (email, password_hash, display_name, status) VALUES (:email, :hash, :display_name, :status)'
- );
- $passwordHash = password_hash($password, PASSWORD_DEFAULT);
- $insert->execute([
- 'email' => $email,
- 'hash' => $passwordHash,
- 'display_name' => $displayName !== '' ? $displayName : explode('@', $email)[0],
- 'status' => 'active',
- ]);
-
- $newId = (int) $db->lastInsertId();
- self::startSession($newId, $passwordHash);
-
- return [true, null];
- }
-
- public static function attemptLogin(string $email, string $password): bool
- {
- $email = trim(strtolower($email));
- $db = Database::users();
-
- $statement = $db->prepare('SELECT * FROM user_accounts WHERE email = :email AND status = :status LIMIT 1');
- $statement->execute(['email' => $email, 'status' => 'active']);
- $account = $statement->fetch();
-
- if (!$account || !password_verify($password, $account['password_hash'])) {
- return false;
- }
-
- self::startSession((int) $account['id'], (string) $account['password_hash']);
-
- $update = $db->prepare('UPDATE user_accounts SET last_login_at = NOW() WHERE id = :id');
- $update->execute(['id' => $account['id']]);
-
- return true;
- }
-
- private static function startSession(int $userId, string $passwordHash): void
- {
- session_regenerate_id(true);
- $_SESSION['user_account_id'] = $userId;
- $_SESSION['user_password_fp'] = self::passwordFingerprint($passwordHash);
- self::$current = null;
-
- try {
- self::revokeCurrentRememberToken();
- self::issueRememberToken($userId);
- $_SESSION['user_remember_checked'] = time();
- } catch (PDOException $e) {
- error_log('Publisium: could not issue reader remember token: ' . $e->getMessage());
- }
- }
-
- public static function logout(): void
- {
- self::revokeCurrentRememberToken();
- self::clearRememberCookie();
-
- unset($_SESSION['user_account_id'], $_SESSION['user_password_fp'], $_SESSION['user_remember_checked']);
- self::$current = null;
-
- if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
- session_regenerate_id(true);
- }
- }
-
- public static function passwordChanged(int $userId, string $newPasswordHash): void
- {
- self::revokeAllRememberTokens($userId);
- self::startSession($userId, $newPasswordHash);
- }
-
- public static function revokeAllRememberTokens(int $userId): void
- {
- try {
- $statement = Database::users()->prepare('DELETE FROM user_remember_tokens WHERE user_account_id = :id');
- $statement->execute(['id' => $userId]);
- } catch (PDOException $e) {
- error_log('Publisium: could not revoke reader remember tokens: ' . $e->getMessage());
- }
- }
-
- private static function syncRememberToken(): void
- {
- if (headers_sent()) {
- return;
- }
-
- if (self::check() && (int) ($_SESSION['user_remember_checked'] ?? 0) > time() - self::REMEMBER_CHECK_SECONDS) {
- return;
- }
-
- $token = self::findRememberToken();
-
- if (!self::check()) {
- if ($token === null) {
- if (isset($_COOKIE[self::REMEMBER_COOKIE])) {
- self::clearRememberCookie();
- }
- return;
- }
-
- session_regenerate_id(true);
- $_SESSION['user_account_id'] = (int) $token['user_account_id'];
- $_SESSION['user_password_fp'] = self::passwordFingerprint((string) $token['password_hash']);
- $_SESSION['user_remember_checked'] = 0;
- }
-
- $userId = (int) $_SESSION['user_account_id'];
-
- if ($token !== null && (int) $token['user_account_id'] === $userId) {
- if ($token['rotated_at'] !== null) {
- return;
- }
- if ((int) $token['rotation_due'] === 1 && !self::rotateRememberToken((int) $token['id'], $userId)) {
- return;
- }
- $_SESSION['user_remember_checked'] = time();
- return;
- }
-
- if (self::user() === null) {
- return;
- }
- self::revokeCurrentRememberToken();
- self::issueRememberToken($userId);
- $_SESSION['user_remember_checked'] = time();
- }
-
- private static function findRememberToken(): ?array
- {
- $raw = $_COOKIE[self::REMEMBER_COOKIE] ?? null;
- if (!is_string($raw) || preg_match('/^[a-f0-9]{64}$/', $raw) !== 1) {
- return null;
- }
-
- try {
- $statement = Database::users()->prepare(sprintf(
- "SELECT t.id, t.user_account_id, t.rotated_at, (t.created_at < NOW() - INTERVAL 1 DAY) AS rotation_due, a.password_hash
- FROM user_remember_tokens t
- JOIN user_accounts a ON a.id = t.user_account_id AND a.status = 'active'
- WHERE t.token_hash = :hash AND t.expires_at > NOW()
- AND (t.rotated_at IS NULL OR t.rotated_at > NOW() - INTERVAL %d SECOND)
- LIMIT 1",
- self::REMEMBER_GRACE_SECONDS
- ));
- $statement->execute(['hash' => hash('sha256', $raw)]);
- $token = $statement->fetch();
- } catch (PDOException $e) {
- return null;
- }
-
- return $token ?: null;
- }
-
- private static function rotateRememberToken(int $tokenId, int $userId): bool
- {
- $statement = Database::users()->prepare(
- 'UPDATE user_remember_tokens SET rotated_at = NOW() WHERE id = :id AND rotated_at IS NULL'
- );
- $statement->execute(['id' => $tokenId]);
-
- if ($statement->rowCount() === 0) {
- return false;
- }
-
- self::issueRememberToken($userId);
- return true;
- }
-
- private static function issueRememberToken(int $userId): void
- {
- self::ensureRememberTable();
- $db = Database::users();
-
- $db->exec('DELETE FROM user_remember_tokens WHERE expires_at < NOW() OR rotated_at < NOW() - INTERVAL 1 DAY');
-
- $raw = bin2hex(random_bytes(32));
- $insert = $db->prepare(sprintf(
- 'INSERT INTO user_remember_tokens (user_account_id, token_hash, expires_at, ip_address, user_agent)
- VALUES (:user_id, :hash, NOW() + INTERVAL %d DAY, :ip, :user_agent)',
- self::REMEMBER_DAYS
- ));
- $insert->execute([
- 'user_id' => $userId,
- 'hash' => hash('sha256', $raw),
- 'ip' => substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45),
- 'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
- ]);
-
- self::setRememberCookie($raw, time() + self::REMEMBER_DAYS * 86400);
- }
-
- private static function revokeCurrentRememberToken(): void
- {
- $raw = $_COOKIE[self::REMEMBER_COOKIE] ?? null;
- if (!is_string($raw) || preg_match('/^[a-f0-9]{64}$/', $raw) !== 1) {
- return;
- }
-
- try {
- $statement = Database::users()->prepare('DELETE FROM user_remember_tokens WHERE token_hash = :hash');
- $statement->execute(['hash' => hash('sha256', $raw)]);
- } catch (PDOException $e) {
- }
- }
-
- private static function setRememberCookie(string $value, int $expires): void
- {
- if (!headers_sent()) {
- header('Cache-Control: private, no-store');
- setcookie(self::REMEMBER_COOKIE, $value, [
- 'expires' => $expires,
- 'path' => '/',
- 'secure' => Config::cookieSecure(),
- 'httponly' => true,
- 'samesite' => 'Lax',
- ]);
- }
- $_COOKIE[self::REMEMBER_COOKIE] = $value;
- }
-
- private static function clearRememberCookie(): void
- {
- if (isset($_COOKIE[self::REMEMBER_COOKIE]) && !headers_sent()) {
- header('Cache-Control: private, no-store');
- setcookie(self::REMEMBER_COOKIE, '', [
- 'expires' => time() - 3600,
- 'path' => '/',
- 'secure' => Config::cookieSecure(),
- 'httponly' => true,
- 'samesite' => 'Lax',
- ]);
- }
- unset($_COOKIE[self::REMEMBER_COOKIE]);
- }
-
- private static function ensureRememberTable(): void
- {
- if (self::$rememberTableReady) {
- return;
- }
-
- Database::users()->exec(
- 'CREATE TABLE IF NOT EXISTS user_remember_tokens (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- user_account_id INT UNSIGNED NOT NULL,
- token_hash CHAR(64) NOT NULL,
- expires_at DATETIME NOT NULL,
- rotated_at DATETIME NULL,
- ip_address VARCHAR(45) NULL,
- user_agent VARCHAR(255) NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE KEY uq_remember_token_hash (token_hash),
- KEY idx_remember_account (user_account_id),
- KEY idx_remember_expires (expires_at),
- KEY idx_remember_rotated (rotated_at),
- CONSTRAINT fk_remember_account FOREIGN KEY (user_account_id) REFERENCES user_accounts(id) ON DELETE CASCADE
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
- );
- self::$rememberTableReady = true;
- }
-
- private static function passwordFingerprint(string $passwordHash): string
- {
- return hash('sha256', $passwordHash);
- }
-
- public static function check(): bool
- {
- return isset($_SESSION['user_account_id']);
- }
-
- public static function user(): ?array
- {
- if (!self::check()) {
- return null;
- }
-
- if (self::$current !== null) {
- return self::$current;
- }
-
- $statement = Database::users()->prepare('SELECT * FROM user_accounts WHERE id = :id LIMIT 1');
- $statement->execute(['id' => $_SESSION['user_account_id']]);
- $account = $statement->fetch();
-
- if (!$account || $account['status'] !== 'active') {
- self::logout();
- return null;
- }
-
- $fingerprint = self::passwordFingerprint((string) $account['password_hash']);
- if (!isset($_SESSION['user_password_fp'])) {
- $_SESSION['user_password_fp'] = $fingerprint;
- } elseif (!hash_equals((string) $_SESSION['user_password_fp'], $fingerprint)) {
- self::logout();
- return null;
- }
-
- self::$current = $account;
- return self::$current;
- }
-
- public static function hasActiveSubscription(int $userAccountId): bool
- {
- $statement = Database::users()->prepare(
- "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"
- );
- $statement->execute(['id' => $userAccountId]);
-
- return (bool) $statement->fetch();
- }
-
- public static function requireLogin(): void
- {
- if (self::user() === null) {
- header('Location: user-login.php');
- exit;
- }
- }
- }
-