Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.67% covered (success)
91.67%
88 / 96
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlSessionHandler
91.58% covered (success)
91.58%
87 / 95
87.50% covered (warning)
87.50%
7 / 8
36.77
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 open
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 read
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 isSessionExpiredOrHijacked
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 write
81.82% covered (warning)
81.82%
36 / 44
0.00% covered (danger)
0.00%
0 / 1
15.18
 destroy
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 gc
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Session;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use PDO;
12use SessionHandlerInterface;
13
14/**
15 * PDO-backed MySQL Custom Session Handler.
16 *
17 * Implements SessionHandlerInterface to store session data in table {prefix}mod_user_sessions.
18 *
19 * @package App\Core\Session
20 */
21final class SqlSessionHandler implements SessionHandlerInterface
22{
23    /** @var array<string, array{data: string, time: int}> Session read snapshot cache for debouncing writes. */
24    private array $readSnapshots = [];
25
26    /**
27     * SqlSessionHandler constructor.
28     *
29     * @param PDO    $pdo                     PDO database connection instance.
30     * @param string $tablePrefix             Database table prefix (default 'a_').
31     * @param int    $idleTimeoutSeconds      Inactivity timeout in seconds (default: 1800 = 30m).
32     * @param int    $absoluteLifetimeSeconds Absolute maximum session lifetime in seconds (default: 43200 = 12h).
33     * @param bool   $enableAgentBinding      Whether to enforce matching User-Agent.
34     */
35    public function __construct(
36        private readonly PDO $pdo,
37        private readonly string $tablePrefix = 'a_',
38        private readonly int $idleTimeoutSeconds = 1800,
39        private readonly int $absoluteLifetimeSeconds = 43200,
40        private readonly bool $enableAgentBinding = true
41    ) {
42    }
43
44    /**
45     * Opens session storage.
46     *
47     * @param string $path Session save path.
48     * @param string $name Session cookie name.
49     * @return bool True on success.
50     */
51    public function open(string $path, string $name): bool
52    {
53        return true;
54    }
55
56    /**
57     * Closes session storage.
58     *
59     * @return bool True on success.
60     */
61    public function close(): bool
62    {
63        return true;
64    }
65
66    /**
67     * Reads session data by session token string with inactivity, absolute lifetime, and UA check.
68     *
69     * @param string $id Session ID string.
70     * @return string Serialized session data or empty string if not found or expired.
71     */
72    public function read(string $id): string
73    {
74        $table = $this->tablePrefix . 'mod_user_sessions';
75        $sql = sprintf(
76            'SELECT `data`, `last_activity`, `created_at`, `user_agent` FROM `%s` WHERE `session` = :session LIMIT 1',
77            $table
78        );
79
80        $stmt = $this->pdo->prepare($sql);
81        $stmt->execute([':session' => $id]);
82
83        $row = $stmt->fetch(PDO::FETCH_ASSOC);
84        if ($row === false || !isset($row['data'])) {
85            return '';
86        }
87
88        if ($this->isSessionExpiredOrHijacked($row)) {
89            $this->destroy($id);
90            return '';
91        }
92
93        $dataStr = (string) $row['data'];
94        $this->readSnapshots[$id] = ['data' => $dataStr, 'time' => time()];
95        return $dataStr;
96    }
97
98    /**
99     * Checks if session has expired (idle or absolute) or has mismatched client agent fingerprint.
100     *
101     * @param array<string, mixed> $row Database row from sessions table.
102     * @return bool True if invalid/expired/hijacked.
103     */
104    private function isSessionExpiredOrHijacked(array $row): bool
105    {
106        $now = time();
107        $lastActivity = (int) ($row['last_activity'] ?? 0);
108        if ($this->idleTimeoutSeconds > 0 && ($now - $lastActivity) > $this->idleTimeoutSeconds) {
109            return true;
110        }
111
112        $createdAt = strtotime((string) ($row['created_at'] ?? ''));
113        if ($this->absoluteLifetimeSeconds > 0 && $createdAt > 0
114            && ($now - $createdAt) > $this->absoluteLifetimeSeconds) {
115            return true;
116        }
117
118        if ($this->enableAgentBinding && !empty($row['user_agent'])) {
119            $currentUa = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
120            if ($currentUa !== '' && !hash_equals((string) $row['user_agent'], $currentUa)) {
121                return true;
122            }
123        }
124
125        return false;
126    }
127
128    /**
129     * Writes session data to database.
130     *
131     * @param string $id Session ID string.
132     * @param string $data Serialized session data string.
133     * @return bool True on success.
134     */
135    public function write(string $id, string $data): bool
136    {
137        $lastActivity = time();
138
139        if (isset($this->readSnapshots[$id])) {
140            $prev = $this->readSnapshots[$id];
141            if ($prev['data'] === $data && ($lastActivity - $prev['time']) < 60) {
142                return true;
143            }
144        }
145
146        $table = $this->tablePrefix . 'mod_user_sessions';
147        $userId = !empty($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
148        $userType = !empty($_SESSION['user_type']) ? (string)$_SESSION['user_type'] : 'administrator';
149        $authLogId = !empty($_SESSION['auth_log_id']) ? (int)$_SESSION['auth_log_id'] : null;
150        $ipAddress = $_SERVER['REMOTE_ADDR'] ?? null;
151        $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
152
153        if ($userAgent !== null && strlen($userAgent) > 512) {
154            // @codeCoverageIgnoreStart
155            $userAgent = substr($userAgent, 0, 512);
156            // @codeCoverageIgnoreEnd
157        }
158
159        $sql = sprintf(
160            'INSERT INTO `%s` (`session`, `user_id`, `user_type`, `auth_log_id`, `ip_address`, `user_agent`, `data`, ' .
161            '`last_activity`) VALUES (:session, :user_id, :user_type, :auth_log_id, :ip_address, :user_agent, :data, ' .
162            ':last_activity) ON DUPLICATE KEY UPDATE `user_id` = VALUES(`user_id`), ' .
163            '`user_type` = VALUES(`user_type`), `auth_log_id` = VALUES(`auth_log_id`), ' .
164            '`ip_address` = VALUES(`ip_address`), `user_agent` = VALUES(`user_agent`), ' .
165            '`data` = VALUES(`data`), `last_activity` = VALUES(`last_activity`)',
166            $table
167        );
168
169        $params = [
170            ':session'       => $id,
171            ':user_id'       => $userId,
172            ':user_type'     => $userType,
173            ':auth_log_id'   => $authLogId,
174            ':ip_address'    => $ipAddress,
175            ':user_agent'    => $userAgent,
176            ':data'          => $data,
177            ':last_activity' => $lastActivity,
178        ];
179
180        try {
181            $stmt = $this->pdo->prepare($sql);
182            $success = $stmt !== false && $stmt->execute($params);
183            if ($success) {
184                $this->readSnapshots[$id] = ['data' => $data, 'time' => $lastActivity];
185            }
186            return $success;
187        } catch (\Throwable) {
188            $params[':user_id'] = null;
189            $params[':auth_log_id'] = null;
190            $stmt = $this->pdo->prepare($sql);
191            $success = $stmt !== false && $stmt->execute($params);
192            if ($success) {
193                $this->readSnapshots[$id] = ['data' => $data, 'time' => $lastActivity];
194            }
195            return $success;
196        }
197    }
198
199    /**
200     * Destroys session by ID token.
201     *
202     * @param string $id Session ID string.
203     * @return bool True on success.
204     */
205    public function destroy(string $id): bool
206    {
207        unset($this->readSnapshots[$id]);
208        $table = $this->tablePrefix . 'mod_user_sessions';
209        $sql = sprintf('DELETE FROM `%s` WHERE `session` = :session', $table);
210
211        $stmt = $this->pdo->prepare($sql);
212        return $stmt->execute([':session' => $id]);
213    }
214
215    /**
216     * Cleans up expired sessions (Garbage Collection).
217     *
218     * @param int $max_lifetime Maximum session lifetime in seconds.
219     * @return int|false Number of deleted session records or false on failure.
220     */
221    public function gc(int $max_lifetime): int|false
222    {
223        $table = $this->tablePrefix . 'mod_user_sessions';
224        $effectiveIdle = $this->idleTimeoutSeconds > 0 ? $this->idleTimeoutSeconds : $max_lifetime;
225        $idleCutoff = time() - min($max_lifetime, $effectiveIdle);
226        $absCutoff = $this->absoluteLifetimeSeconds > 0 ? $this->absoluteLifetimeSeconds : 43200;
227
228        $sql = sprintf(
229            'DELETE FROM `%s` WHERE `last_activity` < :idle_cutoff ' .
230            'OR `created_at` < DATE_SUB(NOW(6), INTERVAL :abs SECOND)',
231            $table
232        );
233
234        $stmt = $this->pdo->prepare($sql);
235        $stmt->bindValue(':idle_cutoff', $idleCutoff, PDO::PARAM_INT);
236        $stmt->bindValue(':abs', $absCutoff, PDO::PARAM_INT);
237
238        if ($stmt->execute()) {
239            return $stmt->rowCount();
240        }
241
242        // @codeCoverageIgnoreStart
243        return false;
244        // @codeCoverageIgnoreEnd
245    }
246}