Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.51% covered (warning)
86.51%
109 / 126
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
RedisHandler
86.51% covered (warning)
86.51%
109 / 126
76.92% covered (warning)
76.92%
10 / 13
53.66
0.00% covered (danger)
0.00%
0 / 1
 prepareConfig
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 setRedis
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRedis
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 open
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 read
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 write
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 updateTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 close
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 destroy
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 gc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lock
55.56% covered (warning)
55.56%
15 / 27
0.00% covered (danger)
0.00%
0 / 1
16.11
 unlock
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Session Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Session\SaveHandlers;
11
12use Framework\Log\LogLevel;
13use Framework\Session\SaveHandler;
14use Redis;
15use RedisException;
16use SensitiveParameter;
17
18/**
19 * Class RedisHandler.
20 *
21 * @package session
22 */
23class RedisHandler extends SaveHandler
24{
25    protected ?Redis $redis;
26
27    /**
28     * Prepare configurations to be used by the RedisHandler.
29     *
30     * @param array<string,mixed> $config Custom configs
31     *
32     * The custom configs are:
33     *
34     * ```php
35     * $configs = [
36     *     // A custom prefix prepended in the keys
37     *     'prefix' => '',
38     *     // The Redis host
39     *     'host' => '127.0.0.1',
40     *     // The Redis host port
41     *     'port' => 6379,
42     *     // The connection timeout
43     *     'timeout' => 0.0,
44     *     // Optional auth password
45     *     'password' => null,
46     *     // Optional database to select
47     *     'database' => null,
48     *     // Maximum attempts to try lock a session id
49     *     'lock_attempts' => 60,
50     *     // Interval between the lock attempts in microseconds
51     *     'lock_sleep' => 1_000_000,
52     *     // TTL to the lock (valid for the current session only)
53     *     'lock_ttl' => 600,
54     *     // The maxlifetime (TTL) used for cache item expiration
55     *     'maxlifetime' => null, // Null to use the ini value of session.gc_maxlifetime
56     *     // Match IP?
57     *     'match_ip' => false,
58     *     // Match User-Agent?
59     *     'match_ua' => false,
60     * ];
61     * ```
62     */
63    protected function prepareConfig(#[SensitiveParameter] array $config) : void
64    {
65        $this->config = \array_replace([
66            'prefix' => '',
67            'host' => '127.0.0.1',
68            'port' => 6379,
69            'timeout' => 0.0,
70            'password' => null,
71            'database' => null,
72            'lock_attempts' => 60,
73            'lock_sleep' => 1_000_000,
74            'lock_ttl' => 600,
75            'maxlifetime' => null,
76            'match_ip' => false,
77            'match_ua' => false,
78        ], $config);
79    }
80
81    public function setRedis(Redis $redis) : static
82    {
83        $this->redis = $redis;
84        return $this;
85    }
86
87    public function getRedis() : ?Redis
88    {
89        return $this->redis ?? null;
90    }
91
92    /**
93     * Get a key for Redis, using the optional
94     * prefix, match IP and match User-Agent configs.
95     *
96     * @param string $id The session id
97     *
98     * @return string The final key
99     */
100    protected function getKey(string $id) : string
101    {
102        return $this->config['prefix'] . $id . $this->getKeySuffix();
103    }
104
105    public function open($path, $name) : bool
106    {
107        if (isset($this->redis)) {
108            return true;
109        }
110        $this->redis = new Redis();
111        try {
112            $this->redis->connect(
113                $this->config['host'],
114                $this->config['port'],
115                $this->config['timeout']
116            );
117        } catch (RedisException) {
118            $this->log(
119                'Session (redis): Could not connect to server '
120                . $this->config['host'] . ':' . $this->config['port']
121            );
122            return false;
123        }
124        if (isset($this->config['password'])) {
125            try {
126                $this->redis->auth($this->config['password']);
127            } catch (RedisException) {
128                $this->log('Session (redis): Authentication failed');
129                return false;
130            }
131        }
132        if (isset($this->config['database'])
133            && ! $this->redis->select($this->config['database'])
134        ) {
135            $this->log(
136                "Session (redis): Could not select the database '{$this->config['database']}'"
137            );
138            return false;
139        }
140        return true;
141    }
142
143    public function read($id) : string
144    {
145        if ( ! isset($this->redis) || ! $this->lock($id)) {
146            return '';
147        }
148        if ( ! isset($this->sessionId)) {
149            $this->sessionId = $id;
150        }
151        $data = $this->redis->get($this->getKey($id));
152        \is_string($data) ? $this->sessionExists = true : $data = '';
153        $this->setFingerprint($data);
154        return $data;
155    }
156
157    public function write($id, $data) : bool
158    {
159        if ( ! isset($this->redis)) {
160            return false;
161        }
162        if ($id !== $this->sessionId) {
163            if ( ! $this->unlock() || ! $this->lock($id)) {
164                return false;
165            }
166            $this->sessionExists = false;
167            $this->sessionId = $id;
168        }
169        if ($this->lockId === false) {
170            return false;
171        }
172        $maxlifetime = $this->getMaxlifetime();
173        $this->redis->expire($this->lockId, $this->config['lock_ttl']);
174        if ($this->sessionExists === false || ! $this->hasSameFingerprint($data)) {
175            if ($this->redis->set($this->getKey($id), $data, $maxlifetime)) {
176                $this->setFingerprint($data);
177                $this->sessionExists = true;
178                return true;
179            }
180            return false;
181        }
182        return $this->redis->expire($this->getKey($id), $maxlifetime);
183    }
184
185    public function updateTimestamp($id, $data) : bool
186    {
187        return $this->redis->setex($this->getKey($id), $this->getMaxlifetime(), $data);
188    }
189
190    public function close() : bool
191    {
192        if ( ! isset($this->redis)) {
193            return true;
194        }
195        try {
196            if ($this->redis->ping()) {
197                if ($this->lockId) {
198                    $this->redis->del($this->lockId);
199                }
200                if ( ! $this->redis->close()) {
201                    return false;
202                }
203            }
204        } catch (RedisException $e) {
205            $this->log('Session (redis): Got RedisException on close: ' . $e->getMessage());
206        }
207        $this->redis = null;
208        return true;
209    }
210
211    public function destroy($id) : bool
212    {
213        if ( ! $this->lockId) {
214            return false;
215        }
216        $result = $this->redis->del($this->getKey($id));
217        if ($result !== 1) {
218            $this->log(
219                'Session (redis): Expected to delete 1 key, deleted ' . $result,
220                LogLevel::DEBUG
221            );
222        }
223        return true;
224    }
225
226    public function gc($max_lifetime) : int | false
227    {
228        return 0;
229    }
230
231    protected function lock(string $id) : bool
232    {
233        $ttl = $this->config['lock_ttl'];
234        if ($this->lockId && $this->redis->get($this->lockId)) {
235            return $this->redis->expire($this->lockId, $ttl);
236        }
237        $lockId = $this->getKey($id) . ':lock';
238        $attempt = 0;
239        while ($attempt < $this->config['lock_attempts']) {
240            $attempt++;
241            $oldTtl = $this->redis->ttl($lockId);
242            if ($oldTtl > 0) {
243                \usleep($this->config['lock_sleep']);
244                continue;
245            }
246            if ( ! $this->redis->setex($lockId, $ttl, (string) \time())) {
247                $this->log('Session (redis): Error while trying to lock ' . $lockId);
248                return false;
249            }
250            $this->lockId = $lockId;
251            break;
252        }
253        if ($attempt === $this->config['lock_attempts']) {
254            $this->log(
255                "Session (redis): Unable to lock {$lockId} after {$attempt} attempts"
256            );
257            return false;
258        }
259        if (isset($oldTtl) && $oldTtl === -1) {
260            $this->log(
261                'Session (redis): Lock for ' . $this->getKey($id) . ' had not TTL',
262                LogLevel::DEBUG
263            );
264        }
265        return true;
266    }
267
268    protected function unlock() : bool
269    {
270        if ($this->lockId === false) {
271            return true;
272        }
273        if ( ! $this->redis->del($this->lockId)) {
274            $this->log('Session (redis): Error while trying to unlock ' . $this->lockId);
275            return false;
276        }
277        $this->lockId = false;
278        return true;
279    }
280}