From cf22fe921e0e5b155001c86c5265f2c0a8ad6c68 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Wed, 12 Aug 2026 14:08:00 +0200 Subject: [PATCH] Add remember-me auth token cookie support. Persistent 30-day HttpOnly login via auth_tokens table; Auth integrates tryRestore for session recovery. --- src/Auth.php | 1 + src/AuthRemember.php | 127 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/AuthRemember.php diff --git a/src/Auth.php b/src/Auth.php index b8be792..5acfd88 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -246,6 +246,7 @@ final class Auth { public static function logout(): void { self::clearPending2fa(); + AuthRemember::clear(); unset($_SESSION['user']); } diff --git a/src/AuthRemember.php b/src/AuthRemember.php new file mode 100644 index 0000000..40eb6ca --- /dev/null +++ b/src/AuthRemember.php @@ -0,0 +1,127 @@ +prepare( + "SELECT t.user_id, u.* FROM auth_tokens t + INNER JOIN users u ON u.id = t.user_id + WHERE t.token_hash = ? AND t.purpose = 'login_magic' + AND t.used_at IS NULL AND t.expires_at > ? + LIMIT 1" + ); + $now = gmdate('Y-m-d H:i:s'); + $stmt->execute([$hash, $now]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row || empty($row['id'])) { + self::clearCookie(); + return; + } + $status = (string) ($row['status'] ?? 'active'); + if ($status !== 'active') { + self::clearCookie(); + return; + } + if (AuthFactors::hasTotp((int) $row['id'])) { + // Remember-me skips password but not 2FA. + return; + } + Database::ensureRbacSchema(); + Rbac::seedMembershipsForUser((int) $row['id'], (string) ($row['role'] ?? 'viewer')); + $_SESSION['user'] = Rbac::buildSessionUser($row); + Auth::touchSession(); + } + + public static function issue(int $userId): void { + if ($userId <= 0) { + return; + } + AuthEmailSchema::ensure(Database::pdo()); + $pdo = Database::pdo(); + $pdo->prepare( + "DELETE FROM auth_tokens WHERE user_id = ? AND purpose = 'login_magic' AND used_at IS NULL" + )->execute([$userId]); + $raw = bin2hex(random_bytes(32)); + $hash = hash('sha256', $raw); + $expires = gmdate('Y-m-d H:i:s', time() + self::TTL_SECONDS); + $pdo->prepare( + 'INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?)' + )->execute([$userId, 'login_magic', $hash, $expires, gmdate('Y-m-d H:i:s')]); + self::setCookie($raw, self::TTL_SECONDS); + } + + public static function clear(): void { + $raw = trim((string) ($_COOKIE[self::COOKIE] ?? '')); + if ($raw !== '' && preg_match('/^[a-f0-9]{64}$/', $raw)) { + AuthEmailSchema::ensure(Database::pdo()); + $hash = hash('sha256', $raw); + Database::pdo()->prepare( + "UPDATE auth_tokens SET used_at = ? WHERE token_hash = ? AND purpose = 'login_magic' AND used_at IS NULL" + )->execute([gmdate('Y-m-d H:i:s'), $hash]); + } + self::clearCookie(); + } + + public static function stashPendingFromRequest(): void { + if (!empty($_POST['remember_me'])) { + $_SESSION['remember_me_after_login'] = 1; + } else { + unset($_SESSION['remember_me_after_login']); + } + } + + public static function applyPending(int $userId): void { + if ($userId <= 0 || empty($_SESSION['remember_me_after_login'])) { + unset($_SESSION['remember_me_after_login']); + return; + } + unset($_SESSION['remember_me_after_login']); + self::issue($userId); + } + + private static function cookiePath(): string { + $path = trim((string) cfg('session_cookie_path', '/app/androidcast_project')); + return $path !== '' ? rtrim($path, '/') : '/'; + } + + private static function setCookie(string $value, int $maxAge): void { + $secure = function_exists('platform_is_https_request') && platform_is_https_request(); + setcookie(self::COOKIE, $value, [ + 'expires' => time() + $maxAge, + 'path' => self::cookiePath(), + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + $_COOKIE[self::COOKIE] = $value; + } + + private static function clearCookie(): void { + $secure = function_exists('platform_is_https_request') && platform_is_https_request(); + setcookie(self::COOKIE, '', [ + 'expires' => time() - 3600, + 'path' => self::cookiePath(), + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + unset($_COOKIE[self::COOKIE]); + } +}