Proyectos de Subversion Moodle

Rev

Rev 1 | Mostrar el archivo completo | | | Autoría | Ultima modificación | Ver Log |

Rev 1 Rev 1441
Línea 12... Línea 12...
12
// GNU General Public License for more details.
12
// GNU General Public License for more details.
13
//
13
//
14
// You should have received a copy of the GNU General Public License
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
Línea 16... Línea -...
16
 
-
 
17
/**
-
 
18
 * Redis based session handler.
-
 
19
 *
-
 
20
 * @package    core
-
 
21
 * @copyright  2015 Russell Smith <mr-russ@smith2001.net>
-
 
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
-
 
23
 */
-
 
24
 
16
 
Línea 25... Línea 17...
25
namespace core\session;
17
namespace core\session;
-
 
18
 
-
 
19
use coding_exception;
-
 
20
use core\di;
26
 
21
use core\clock;
-
 
22
use RedisCluster;
27
use RedisException;
23
use RedisClusterException;
Línea 28... Línea 24...
28
use RedisClusterException;
24
use RedisException;
29
use SessionHandlerInterface;
25
use SessionHandlerInterface;
30
 
26
 
31
/**
-
 
32
 * Redis based session handler.
-
 
33
 *
-
 
34
 * The default Redis session handler does not handle locking in 2.2.7, so we have written a php session handler
-
 
35
 * that uses locking.  The places where locking is used was modeled from the memcached code that is used in Moodle
27
/**
36
 * https://github.com/php-memcached-dev/php-memcached/blob/master/php_memcached_session.c
28
 * Redis based session handler.
37
 *
29
 *
38
 * @package    core
30
 * @package    core
39
 * @copyright  2016 Russell Smith
31
 * @copyright  Russell Smith
40
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
32
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41
 */
33
 */
Línea 51... Línea 43...
51
    /**
43
    /**
52
     * Compressor: PHP Zstandard.
44
     * Compressor: PHP Zstandard.
53
     */
45
     */
54
    const COMPRESSION_ZSTD      = 'zstd';
46
    const COMPRESSION_ZSTD      = 'zstd';
Línea -... Línea 47...
-
 
47
 
-
 
48
    /** @var string Minimum server version */
-
 
49
    const REDIS_MIN_SERVER_VERSION = "5.0.0";
-
 
50
 
-
 
51
    /** @var string Minimum extension version */
-
 
52
    const REDIS_MIN_EXTENSION_VERSION = "5.1.0";
55
 
53
 
56
    /** @var array $host save_path string  */
54
    /** @var array $host save_path string  */
57
    protected array $host = [];
55
    protected array $host = [];
58
    /** @var int $port The port to connect to */
56
    /** @var int $port The port to connect to */
59
    protected $port = 6379;
57
    protected $port = 6379;
Línea 63... Línea 61...
63
    protected $auth = '';
61
    protected $auth = '';
64
    /** @var int $database the Redis database to store sesions in */
62
    /** @var int $database the Redis database to store sesions in */
65
    protected $database = 0;
63
    protected $database = 0;
66
    /** @var array $servers list of servers parsed from save_path */
64
    /** @var array $servers list of servers parsed from save_path */
67
    protected $prefix = '';
65
    protected $prefix = '';
-
 
66
 
-
 
67
    /** @var string $sessionkeyprefix the prefix for the session key */
-
 
68
    protected string $sessionkeyprefix = 'session_';
-
 
69
 
-
 
70
    /** @var string $userkeyprefix the prefix for the user key */
-
 
71
    protected string $userkeyprefix = 'user_';
-
 
72
 
68
    /** @var int $acquiretimeout how long to wait for session lock in seconds */
73
    /** @var int $acquiretimeout how long to wait for session lock in seconds */
69
    protected $acquiretimeout = 120;
74
    protected $acquiretimeout = 120;
70
    /** @var int $acquirewarn how long before warning when waiting for a lock in seconds */
75
    /** @var int $acquirewarn how long before warning when waiting for a lock in seconds */
71
    protected $acquirewarn = null;
76
    protected $acquirewarn = null;
72
    /** @var int $lockretry how long to wait between session lock attempts in ms */
77
    /** @var int $lockretry how long to wait between session lock attempts in ms */
Línea 76... Línea 81...
76
    /** @var int $compressor The compressor to use */
81
    /** @var int $compressor The compressor to use */
77
    protected $compressor = self::COMPRESSION_NONE;
82
    protected $compressor = self::COMPRESSION_NONE;
78
    /** @var string $lasthash hash of the session data content */
83
    /** @var string $lasthash hash of the session data content */
79
    protected $lasthash = null;
84
    protected $lasthash = null;
Línea -... Línea 85...
-
 
85
 
-
 
86
    /** @var int $gcbatchsize The number of redis keys that will be processed each time the garbage collector is executed. */
-
 
87
    protected int $gcbatchsize = 100;
80
 
88
 
81
    /**
89
    /**
-
 
90
     * How long to wait in seconds before expiring the lock automatically so that other requests may continue execution.
82
     * @var int $lockexpire how long to wait in seconds before expiring the lock automatically
91
     *
83
     * so that other requests may continue execution, ignored if PECL redis is below version 2.2.0.
92
     * @var int $lockexpire
84
     */
93
     */
Línea 85... Línea 94...
85
    protected $lockexpire;
94
    protected int $lockexpire;
86
 
95
 
Línea 87... Línea 96...
87
    /** @var Redis|RedisCluster Connection */
96
    /** @var \Redis|\RedisCluster|null Connection */
88
    protected $connection = null;
97
    protected \Redis|\RedisCluster|null $connection = null;
Línea 89... Línea 98...
89
 
98
 
Línea 95... Línea 104...
95
 
104
 
96
    /** @var bool $clustermode Redis in cluster mode. */
105
    /** @var bool $clustermode Redis in cluster mode. */
Línea 97... Línea 106...
97
    protected bool $clustermode = false;
106
    protected bool $clustermode = false;
98
 
107
 
-
 
108
    /** @var int Maximum number of retries for cache store operations. */
-
 
109
    protected int $maxretries = 3;
-
 
110
 
-
 
111
    /** @var int $firstaccesstimeout The initial timeout (seconds) for the first browser access without login. */
-
 
112
    protected int $firstaccesstimeout = 180;
-
 
113
 
-
 
114
    /** @var clock A clock instance */
-
 
115
    protected clock $clock;
-
 
116
 
Línea 99... Línea 117...
99
    /** @var int Maximum number of retries for cache store operations. */
117
    /** @var int $connectiontimeout The number of seconds to wait for a connection or response from the Redis server. */
100
    const MAX_RETRIES = 5;
118
    protected int $connectiontimeout = 3;
101
 
119
 
102
    /**
120
    /**
Línea 146... Línea 164...
146
 
164
 
147
        if (!empty($CFG->session_redis_serializer_use_igbinary) && defined('\Redis::SERIALIZER_IGBINARY')) {
165
        if (!empty($CFG->session_redis_serializer_use_igbinary) && defined('\Redis::SERIALIZER_IGBINARY')) {
148
            $this->serializer = \Redis::SERIALIZER_IGBINARY; // Set igbinary serializer if phpredis supports it.
166
            $this->serializer = \Redis::SERIALIZER_IGBINARY; // Set igbinary serializer if phpredis supports it.
Línea 149... Línea -...
149
        }
-
 
150
 
-
 
151
        // The following configures the session lifetime in redis to allow some
-
 
152
        // wriggle room in the user noticing they've been booted off and
-
 
153
        // letting them log back in before they lose their session entirely.
-
 
154
        $updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency;
-
 
155
        $this->timeout = $CFG->sessiontimeout + $updatefreq + MINSECS;
167
        }
156
 
168
 
157
        // This sets the Redis session lock expiry time to whatever is lower, either
169
        // This sets the Redis session lock expiry time to whatever is lower, either
-
 
170
        // the PHP execution time `max_execution_time`, if the value is positive, or the
158
        // the PHP execution time `max_execution_time`, if the value was defined in
171
        // globally configured `sessiontimeout`.
159
        // the `php.ini` or the globally configured `sessiontimeout`. Setting it to
172
        //
-
 
173
        // Setting it to the lower of the two will not make things worse it if the execution timeout
160
        // the lower of the two will not make things worse it if the execution timeout
174
        // is longer than the session timeout.
161
        // is longer than the session timeout.
175
        //
-
 
176
        // For the PHP execution time, once the PHP execution time is over, we can be sure
162
        // For the PHP execution time, once the PHP execution time is over, we can be sure
177
        // that the lock is no longer actively held so that the lock can expire safely.
163
        // that the lock is no longer actively held so that the lock can expire safely.
178
        //
164
        // Although at `lib/classes/php_time_limit.php::raise(int)`, Moodle can
179
        // Although at `lib/classes/php_time_limit.php::raise(int)`, Moodle can
165
        // progressively increase the maximum PHP execution time, this is limited to the
180
        // progressively increase the maximum PHP execution time, this is limited to the
166
        // `max_execution_time` value defined in the `php.ini`.
181
        // `max_execution_time` value defined in the `php.ini`.
167
        // For the session timeout, we assume it is safe to consider the lock to expire
182
        // For the session timeout, we assume it is safe to consider the lock to expire
168
        // once the session itself expires.
183
        // once the session itself expires.
-
 
184
        // If we unnecessarily hold the lock any longer, it blocks other session requests.
-
 
185
        $this->lockexpire = ini_get('max_execution_time');
-
 
186
        if ($this->lockexpire < 0) {
-
 
187
            // If the max_execution_time is set to a value lower than 0, which is invalid, use the default value.
-
 
188
            // https://www.php.net/manual/en/info.configuration.php#ini.max-execution-time defines the default as 30.
-
 
189
            // Note: This value is not available programatically.
-
 
190
            $this->lockexpire = 30;
169
        // If we unnecessarily hold the lock any longer, it blocks other session requests.
191
        }
-
 
192
 
-
 
193
        if (empty($this->lockexpire) || ($this->lockexpire > (int)$CFG->sessiontimeout)) {
170
        $this->lockexpire = ini_get('max_execution_time');
194
            // The value of the max_execution_time is either unlimited (0), or higher than the session timeout.
171
        if (empty($this->lockexpire) || ($this->lockexpire > (int)$CFG->sessiontimeout)) {
195
            // Cap it at the session timeout.
-
 
196
            $this->lockexpire = (int)$CFG->sessiontimeout;
172
            $this->lockexpire = (int)$CFG->sessiontimeout;
197
        }
173
        }
198
 
174
        if (isset($CFG->session_redis_lock_expire)) {
199
        if (isset($CFG->session_redis_lock_expire)) {
Línea 175... Línea 200...
175
            $this->lockexpire = (int)$CFG->session_redis_lock_expire;
200
            $this->lockexpire = (int)$CFG->session_redis_lock_expire;
176
        }
201
        }
177
 
202
 
178
        if (isset($CFG->session_redis_compressor)) {
-
 
Línea 179... Línea -...
179
            $this->compressor = $CFG->session_redis_compressor;
-
 
180
        }
203
        if (isset($CFG->session_redis_compressor)) {
181
    }
-
 
182
 
204
            $this->compressor = $CFG->session_redis_compressor;
183
    /**
205
        }
-
 
206
 
184
     * Start the session.
207
        if (isset($CFG->session_redis_connection_timeout)) {
185
     *
208
            $this->connectiontimeout = (int)$CFG->session_redis_connection_timeout;
-
 
209
        }
Línea 186... Línea 210...
186
     * @return bool success
210
 
187
     */
211
        if (isset($CFG->session_redis_max_retries)) {
Línea 188... Línea -...
188
    public function start() {
-
 
189
        $result = parent::start();
212
            $this->maxretries = (int)$CFG->session_redis_max_retries;
190
 
-
 
191
        return $result;
213
        }
192
    }
214
 
193
 
215
        $this->clock = di::get(clock::class);
194
    /**
216
    }
Línea 195... Línea 217...
195
     * Init session handler.
217
 
-
 
218
    #[\Override]
196
     */
219
    public function init(): bool {
-
 
220
        if (!extension_loaded('redis')) {
-
 
221
            throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension is not loaded');
-
 
222
        }
197
    public function init() {
223
 
-
 
224
        if (empty($this->host)) {
198
        if (!extension_loaded('redis')) {
225
            throw new exception(
Línea 199... Línea -...
199
            throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension is not loaded');
-
 
200
        }
226
                'sessionhandlerproblem',
201
 
227
                'error',
-
 
228
                '',
-
 
229
                null,
-
 
230
                '$CFG->session_redis_host must be specified in config.php',
202
        if (empty($this->host)) {
231
            );
-
 
232
        }
203
            throw new exception('sessionhandlerproblem', 'error', '', null,
233
 
Línea 204... Línea 234...
204
                    '$CFG->session_redis_host must be specified in config.php');
234
        $version = phpversion('Redis');
205
        }
235
        if (!$version || version_compare($version, self::REDIS_MIN_EXTENSION_VERSION) <= 0) {
206
 
236
            throw new exception(
Línea 254... Línea 284...
254
                // For a single (non-cluster) Redis, the TLS/SSL config must be added to the 'stream' key.
284
                // For a single (non-cluster) Redis, the TLS/SSL config must be added to the 'stream' key.
255
                $opts['stream'] = $this->sslopts;
285
                $opts['stream'] = $this->sslopts;
256
            }
286
            }
257
        }
287
        }
Línea 258... Línea 288...
258
 
288
 
259
        // MDL-59866: Add retries for connections (up to 5 times) to make sure it goes through.
289
        // Add retries for connections to make sure it goes through.
260
        $counter = 1;
290
        $counter = 1;
261
        $exceptionclass = $this->clustermode ? 'RedisClusterException' : 'RedisException';
291
        $exceptionclass = $this->clustermode ? 'RedisClusterException' : 'RedisException';
262
        while ($counter <= self::MAX_RETRIES) {
292
        while ($counter <= $this->maxretries) {
263
            $this->connection = null;
293
            $this->connection = null;
264
            // Make a connection to Redis server(s).
294
            // Make a connection to Redis server(s).
265
            try {
295
            try {
-
 
296
                // Create a $redis object of a RedisCluster or Redis class.
266
                // Create a $redis object of a RedisCluster or Redis class.
297
                $phpredisversion = phpversion('redis');
-
 
298
                if ($this->clustermode) {
-
 
299
                    if (version_compare($phpredisversion, '6.0.0', '>=')) {
267
                if ($this->clustermode) {
300
                        // Named parameters are fully supported starting from version 6.0.0.
-
 
301
                        $this->connection = new \RedisCluster(
-
 
302
                            name: null,
-
 
303
                            seeds: $trimmedservers,
-
 
304
                            timeout: $this->connectiontimeout, // Timeout.
-
 
305
                            read_timeout: $this->connectiontimeout, // Read timeout.
-
 
306
                            persistent: true,
268
                    $this->connection = new \RedisCluster(null, $trimmedservers, 1, 1, true,
307
                            auth: $this->auth,
-
 
308
                            context: !empty($opts) ? $opts : null,
-
 
309
                        );
-
 
310
                    } else {
-
 
311
                        $this->connection = new \RedisCluster(
-
 
312
                            null,
-
 
313
                            $trimmedservers,
-
 
314
                            $this->connectiontimeout,
-
 
315
                            $this->connectiontimeout,
-
 
316
                            true,
-
 
317
                            $this->auth,
-
 
318
                            !empty($opts) ? $opts : null
-
 
319
                        );
269
                        $this->auth, !empty($opts) ? $opts : null);
320
                    }
270
                } else {
321
                } else {
271
                    $delay = rand(100, 500);
322
                    $delay = rand(100, 500);
272
                    $this->connection = new \Redis();
323
                    $this->connection = new \Redis();
-
 
324
                    if (version_compare($phpredisversion, '6.0.0', '>=')) {
-
 
325
                        // Named parameters are fully supported starting from version 6.0.0.
-
 
326
                        $this->connection->connect(
-
 
327
                            host: $server,
-
 
328
                            port: $port,
-
 
329
                            timeout: $this->connectiontimeout, // Timeout.
-
 
330
                            retry_interval: $delay,
-
 
331
                            read_timeout: $this->connectiontimeout, // Read timeout.
-
 
332
                            context: $opts,
-
 
333
                        );
-
 
334
                    } else {
-
 
335
                        $this->connection->connect(
-
 
336
                            $server,
-
 
337
                            $port,
-
 
338
                            $this->connectiontimeout,
-
 
339
                            null,
-
 
340
                            $delay,
-
 
341
                            $this->connectiontimeout,
-
 
342
                            $opts
-
 
343
                        );
-
 
344
                    }
273
                    $this->connection->connect($server, $port, 1, null, $delay, 1, $opts);
345
 
274
                    if ($this->auth !== '' && !$this->connection->auth($this->auth)) {
346
                    if ($this->auth !== '' && !$this->connection->auth($this->auth)) {
275
                        throw new $exceptionclass('Unable to authenticate.');
347
                        throw new $exceptionclass('Unable to authenticate.');
276
                    }
348
                    }
Línea 283... Línea 355...
283
                    // Use custom prefix on sessions.
355
                    // Use custom prefix on sessions.
284
                    if (!$this->connection->setOption(\Redis::OPT_PREFIX, $this->prefix)) {
356
                    if (!$this->connection->setOption(\Redis::OPT_PREFIX, $this->prefix)) {
285
                        throw new $exceptionclass('Unable to set the Redis Prefix option.');
357
                        throw new $exceptionclass('Unable to set the Redis Prefix option.');
286
                    }
358
                    }
287
                }
359
                }
-
 
360
 
-
 
361
                // Check the server version.
-
 
362
                // The session handler requires a version of Redis server with support for SET command options (at least 2.6.12).
-
 
363
                // Note: In the case of a TLS connection, the connection will hang if the phpredis client does not communicate
-
 
364
                // with the server immediately after connect(). See https://github.com/phpredis/phpredis/issues/2332.
-
 
365
                // This version check satisfies that requirement.
-
 
366
                try {
288
                if ($this->sslopts && !$this->connection->ping('Ping')) {
367
                    $serverversion = $this->connection->info('server')['redis_version'];
289
                    // In case of a TLS connection,
368
                } catch (RedisException | RedisClusterException $e) {
290
                    // if phpredis client does not communicate immediately with the server the connection hangs.
369
                    // Some proxies e.g envoy or twemproxy lack support of INFO command. So just assume we meet the minimum
291
                    // See https://github.com/phpredis/phpredis/issues/2332.
370
                    // version requirement.
-
 
371
                    $serverversion = self::REDIS_MIN_SERVER_VERSION;
-
 
372
                }
-
 
373
                if (version_compare($serverversion, self::REDIS_MIN_SERVER_VERSION) < 0) {
292
                    throw new $exceptionclass("Ping failed");
374
                    throw new $exceptionclass(sprintf(
-
 
375
                        "Version %s is not supported. The minimum version required is %s.",
-
 
376
                        $serverversion,
-
 
377
                        self::REDIS_MIN_SERVER_VERSION,
-
 
378
                    ));
293
                }
379
                }
-
 
380
 
294
                if ($this->database !== 0) {
381
                if ($this->database !== 0) {
295
                    if (!$this->connection->select($this->database)) {
382
                    if (!$this->connection->select($this->database)) {
296
                        throw new $exceptionclass('Unable to select the Redis database ' . $this->database . '.');
383
                        throw new $exceptionclass('Unable to select the Redis database ' . $this->database . '.');
297
                    }
384
                    }
298
                }
385
                }
-
 
386
 
299
                return true;
387
                return true;
300
            } catch (RedisException | RedisClusterException $e) {
388
            } catch (RedisException | RedisClusterException $e) {
301
                $redishost = $this->clustermode ? implode(',', $this->host) : $server. ':'. $port;
389
                $redishost = $this->clustermode ? implode(',', $this->host) : $server . ':' . $port;
302
                $logstring = "Failed to connect (try {$counter} out of " . self::MAX_RETRIES . ") to Redis ";
390
                $logstring = "Failed to connect (try {$counter} out of " . $this->maxretries . ") to Redis ";
303
                $logstring .= "at ". $redishost .", the error returned was: {$e->getMessage()}";
391
                $logstring .= "at ". $redishost .", the error returned was: {$e->getMessage()}";
304
                debugging($logstring);
392
                debugging($logstring);
305
            }
393
            }
306
            $counter++;
394
            $counter++;
307
            // Introduce a random sleep between 100ms and 500ms.
395
            // Introduce a random sleep between 100ms and 500ms.
Línea 315... Línea 403...
315
 
403
 
316
        $result = session_set_save_handler($this);
404
        $result = session_set_save_handler($this);
317
        if (!$result) {
405
        if (!$result) {
318
            throw new exception('redissessionhandlerproblem', 'error');
406
            throw new exception('redissessionhandlerproblem', 'error');
-
 
407
        }
319
        }
408
        return false;
Línea 320... Línea 409...
320
    }
409
    }
321
 
410
 
322
    /**
411
    /**
Línea 337... Línea 426...
337
     */
426
     */
338
    public function close(): bool {
427
    public function close(): bool {
339
        $this->lasthash = null;
428
        $this->lasthash = null;
340
        try {
429
        try {
341
            foreach ($this->locks as $id => $expirytime) {
430
            foreach ($this->locks as $id => $expirytime) {
342
                if ($expirytime > $this->time()) {
431
                if ($expirytime > $this->clock->time()) {
343
                    $this->unlock_session($id);
432
                    $this->unlock_session($id);
344
                }
433
                }
345
                unset($this->locks[$id]);
434
                unset($this->locks[$id]);
346
            }
435
            }
347
        } catch (RedisException | RedisClusterException $e) {
436
        } catch (RedisException | RedisClusterException $e) {
Línea 354... Línea 443...
354
 
443
 
355
    /**
444
    /**
356
     * Read the session data from storage
445
     * Read the session data from storage
357
     *
446
     *
358
     * @param string $id The session id to read from storage.
447
     * @param string $id The session id to read from storage.
359
     * @return string The session data for PHP to process.
448
     * @return string|false The session data for PHP to process or false.
360
     *
449
     *
361
     * @throws RedisException when we are unable to talk to the Redis server.
450
     * @throws RedisException when we are unable to talk to the Redis server.
362
     */
451
     */
363
    public function read(string $id): string|false {
452
    public function read(string $id): string|false {
364
        try {
453
        try {
365
            if ($this->requires_write_lock()) {
454
            if ($this->requires_write_lock()) {
366
                $this->lock_session($id);
455
                $this->lock_session($this->sessionkeyprefix . $id);
-
 
456
            }
-
 
457
 
-
 
458
            $keys = $this->connection->hmget($this->sessionkeyprefix . $id, ['userid', 'sessdata']);
367
            }
459
            $userid = $keys['userid'];
Línea 368... Línea 460...
368
            $sessiondata = $this->uncompress($this->connection->get($id));
460
            $sessiondata = $this->uncompress($keys['sessdata']);
369
 
461
 
370
            if ($sessiondata === false) {
462
            if ($sessiondata === false) {
371
                if ($this->requires_write_lock()) {
463
                if ($this->requires_write_lock()) {
372
                    $this->unlock_session($id);
464
                    $this->unlock_session($this->sessionkeyprefix . $id);
373
                }
465
                }
374
                $this->lasthash = sha1('');
466
                $this->lasthash = sha1('');
-
 
467
                return '';
-
 
468
            }
-
 
469
 
-
 
470
            // Do not update expiry if non-login user (0). This would affect the first access timeout.
375
                return '';
471
            if ($userid != 0) {
-
 
472
                $maxlifetime = $this->get_maxlifetime($userid);
-
 
473
                $this->connection->expire($this->sessionkeyprefix . $id, $maxlifetime);
376
            }
474
                $this->connection->expire($this->userkeyprefix . $userid, $maxlifetime);
377
            $this->connection->expire($id, $this->timeout);
475
            }
378
        } catch (RedisException | RedisClusterException $e) {
476
        } catch (RedisException | RedisClusterException $e) {
379
            error_log('Failed talking to redis: '.$e->getMessage());
477
            error_log('Failed talking to redis: '.$e->getMessage());
-
 
478
            throw $e;
-
 
479
        }
-
 
480
 
-
 
481
        // Update last hash.
-
 
482
        if ($sessiondata === null) {
-
 
483
            // As of PHP 8.1 we can't pass null to base64_encode.
-
 
484
            $sessiondata = '';
380
            throw $e;
485
        }
381
        }
486
 
382
        $this->lasthash = sha1(base64_encode($sessiondata));
487
        $this->lasthash = sha1(base64_encode($sessiondata));
Línea 383... Línea 488...
383
        return $sessiondata;
488
        return $sessiondata;
384
    }
489
    }
385
 
490
 
386
    /**
491
    /**
387
     * Compresses session data.
492
     * Compresses session data.
388
     *
493
     *
389
     * @param mixed $value
494
     * @param mixed $value
390
     * @return string
495
     * @return string
391
     */
496
     */
392
    private function compress($value) {
497
    private function compress($value): string {
393
        switch ($this->compressor) {
498
        switch ($this->compressor) {
394
            case self::COMPRESSION_NONE:
499
            case self::COMPRESSION_NONE:
Línea 436... Línea 541...
436
     * @param string $id session id to write.
541
     * @param string $id session id to write.
437
     * @param string $data session data
542
     * @param string $data session data
438
     * @return bool true on write success, false on failure
543
     * @return bool true on write success, false on failure
439
     */
544
     */
440
    public function write(string $id, string $data): bool {
545
    public function write(string $id, string $data): bool {
441
 
-
 
442
        $hash = sha1(base64_encode($data));
546
        $hash = sha1(base64_encode($data));
Línea 443... Línea 547...
443
 
547
 
444
        // If the content has not changed don't bother writing.
548
        // If the content has not changed don't bother writing.
445
        if ($hash === $this->lasthash) {
549
        if ($hash === $this->lasthash) {
Línea 456... Línea 560...
456
        // PHP does open, read, destroy, write, close. When a session doesn't exist.
560
        // PHP does open, read, destroy, write, close. When a session doesn't exist.
457
        // There can be race conditions on new sessions racing each other but we can
561
        // There can be race conditions on new sessions racing each other but we can
458
        // address that in the future.
562
        // address that in the future.
459
        try {
563
        try {
460
            $data = $this->compress($data);
564
            $data = $this->compress($data);
-
 
565
            $this->connection->hset($this->sessionkeyprefix . $id, 'sessdata', $data);
-
 
566
            $keys = $this->connection->hmget($this->sessionkeyprefix . $id, ['userid', 'timecreated', 'timemodified']);
-
 
567
            $userid = $keys['userid'];
461
 
568
 
-
 
569
            // Don't update expiry if still first access.
-
 
570
            if ($keys['timecreated'] != $keys['timemodified']) {
-
 
571
                $maxlifetime = $this->get_maxlifetime($userid);
462
            $this->connection->setex($id, $this->timeout, $data);
572
                $this->connection->expire($this->sessionkeyprefix . $id, $maxlifetime);
-
 
573
                $this->connection->expire($this->userkeyprefix . $userid, $maxlifetime);
-
 
574
            }
463
        } catch (RedisException | RedisClusterException $e) {
575
        } catch (RedisException | RedisClusterException $e) {
464
            error_log('Failed talking to redis: '.$e->getMessage());
576
            error_log('Failed talking to redis: '.$e->getMessage());
465
            return false;
577
            return false;
466
        }
578
        }
467
        return true;
579
        return true;
468
    }
580
    }
Línea -... Línea 581...
-
 
581
 
-
 
582
    #[\Override]
-
 
583
    public function get_session_by_sid(string $sid): \stdClass {
-
 
584
        $this->init_redis_if_required();
-
 
585
        $keys = ["id", "state", "sid", "userid", "sessdata", "timecreated", "timemodified", "firstip", "lastip"];
-
 
586
        $sessiondata = $this->connection->hmget($this->sessionkeyprefix . $sid, $keys);
-
 
587
 
469
 
588
        return (object)$sessiondata;
-
 
589
    }
-
 
590
 
-
 
591
    #[\Override]
-
 
592
    public function add_session(int $userid): \stdClass {
-
 
593
        $timestamp = $this->clock->time();
-
 
594
        $sid = session_id();
-
 
595
        $maxlifetime = $this->get_maxlifetime($userid, true);
-
 
596
        $sessiondata = [
-
 
597
            'id' => $sid,
-
 
598
            'state' => '0',
-
 
599
            'sid' => $sid,
-
 
600
            'userid' => $userid,
-
 
601
            'sessdata' => null,
-
 
602
            'timecreated' => $timestamp,
-
 
603
            'timemodified' => $timestamp,
-
 
604
            'firstip' => getremoteaddr(),
-
 
605
            'lastip' => getremoteaddr(),
-
 
606
        ];
-
 
607
 
-
 
608
        $userhashkey = $this->userkeyprefix . $userid;
-
 
609
        $this->connection->hSet($userhashkey, $sid, $timestamp);
-
 
610
        $this->connection->expire($userhashkey, $maxlifetime);
-
 
611
 
-
 
612
        $sessionhashkey = $this->sessionkeyprefix . $sid;
-
 
613
        $this->connection->hmSet($sessionhashkey, $sessiondata);
-
 
614
        $this->connection->expire($sessionhashkey, $maxlifetime);
470
    /**
615
 
471
     * Handle destroying a session.
616
        return (object)$sessiondata;
-
 
617
    }
-
 
618
 
-
 
619
    #[\Override]
-
 
620
    public function get_sessions_by_userid(int $userid): array {
-
 
621
        $this->init_redis_if_required();
-
 
622
 
-
 
623
        $userhashkey = $this->userkeyprefix . $userid;
-
 
624
        $sessions = $this->connection->hGetAll($userhashkey);
472
     *
625
        $records = [];
-
 
626
        foreach (array_keys($sessions) as $session) {
-
 
627
            $item = $this->connection->hGetAll($this->sessionkeyprefix . $session);
-
 
628
            if (!empty($item)) {
-
 
629
                $records[] = (object) $item;
-
 
630
            }
-
 
631
        }
-
 
632
        return $records;
-
 
633
    }
-
 
634
 
-
 
635
    #[\Override]
-
 
636
    public function update_session(\stdClass $record): bool {
-
 
637
        if (!isset($record->sid) && isset($record->id)) {
-
 
638
            $record->sid = $record->id;
-
 
639
        }
-
 
640
 
-
 
641
        // If record does not have userid set, we need to get it from the session.
-
 
642
        if (!isset($record->userid)) {
-
 
643
            $session = $this->get_session_by_sid($record->sid);
-
 
644
            $record->userid = $session->userid;
-
 
645
        }
-
 
646
 
-
 
647
        $sessionhashkey = $this->sessionkeyprefix . $record->sid;
-
 
648
        $userhashkey = $this->userkeyprefix . $record->userid;
-
 
649
 
-
 
650
        $recordata = (array) $record;
-
 
651
        unset($recordata['sid']);
-
 
652
        $this->connection->hmSet($sessionhashkey, $recordata);
-
 
653
 
-
 
654
        // Update the expiry time.
-
 
655
        $maxlifetime = $this->get_maxlifetime($record->userid);
-
 
656
        $this->connection->expire($sessionhashkey, $maxlifetime);
-
 
657
        $this->connection->expire($userhashkey, $maxlifetime);
-
 
658
 
-
 
659
        return true;
-
 
660
    }
-
 
661
 
-
 
662
 
-
 
663
    #[\Override]
-
 
664
    public function get_all_sessions(): \Iterator {
-
 
665
        $sessions = [];
-
 
666
        $iterator = null;
-
 
667
        while (false !== ($keys = $this->connection->scan($iterator, '*' . $this->sessionkeyprefix . '*'))) {
-
 
668
            foreach ($keys as $key) {
-
 
669
                $sessions[] = $key;
-
 
670
            }
-
 
671
        }
-
 
672
        return new \ArrayIterator($sessions);
-
 
673
    }
-
 
674
 
-
 
675
    #[\Override]
-
 
676
    public function destroy_all(): bool {
-
 
677
        $this->init_redis_if_required();
-
 
678
 
-
 
679
        $sessions = $this->get_all_sessions();
473
     * @param string $id the session id to destroy.
680
        foreach ($sessions as $session) {
-
 
681
            // Remove the prefixes from the session id, as destroy expects the raw session id.
-
 
682
            if (str_starts_with($session, $this->prefix . $this->sessionkeyprefix)) {
-
 
683
                $session = substr($session, strlen($this->prefix . $this->sessionkeyprefix));
-
 
684
            }
-
 
685
 
-
 
686
            $this->destroy($session);
-
 
687
        }
474
     * @return bool true if the session was deleted, false otherwise.
688
        return true;
-
 
689
    }
-
 
690
 
475
     */
691
    #[\Override]
-
 
692
    public function destroy(string $id): bool {
476
    public function destroy(string $id): bool {
693
        $this->init_redis_if_required();
477
        $this->lasthash = null;
694
        $this->lasthash = null;
-
 
695
        try {
-
 
696
            $sessionhashkey = $this->sessionkeyprefix . $id;
-
 
697
            $userid = $this->connection->hget($sessionhashkey, "userid");
478
        try {
698
            $userhashkey = $this->userkeyprefix . $userid;
-
 
699
            $this->connection->hDel($userhashkey, $id);
479
            $this->connection->del($id);
700
            $this->connection->unlink($sessionhashkey);
480
            $this->unlock_session($id);
701
            $this->unlock_session($id);
481
        } catch (RedisException | RedisClusterException $e) {
702
        } catch (RedisException | RedisClusterException $e) {
482
            error_log('Failed talking to redis: '.$e->getMessage());
703
            error_log('Failed talking to redis: '.$e->getMessage());
483
            return false;
704
            return false;
Línea 484... Línea 705...
484
        }
705
        }
485
 
706
 
Línea -... Línea 707...
-
 
707
        return true;
-
 
708
    }
-
 
709
 
-
 
710
    // phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameUnderscore
-
 
711
    #[\Override]
-
 
712
    public function gc(int $max_lifetime = 0): int|false {
-
 
713
        return 0;
486
        return true;
714
    }
487
    }
715
    // phpcs:enable
488
 
716
 
489
    /**
717
    /**
490
     * Garbage collect sessions.  We don't we any as Redis does it for us.
718
     * Get session maximum lifetime in seconds.
-
 
719
     *
-
 
720
     * @param int|null $userid The user id to calculate the max lifetime for.
491
     *
721
     * @param bool $firstbrowseraccess This indicates that this is calculating the expiry when the key is first added.
492
     * @param integer $max_lifetime All sessions older than this should be removed.
722
     *                                 The first access made by the browser has a shorter timeout to reduce abandoned sessions.
-
 
723
     * @return float|int
-
 
724
     */
-
 
725
    private function get_maxlifetime(?int $userid = null, bool $firstbrowseraccess = false): float|int {
-
 
726
        global $CFG;
-
 
727
 
-
 
728
        // Guest user.
-
 
729
        if ($userid == $CFG->siteguest) {
-
 
730
            return $CFG->sessiontimeout * 5;
493
     * @return bool true, as Redis handles expiry for us.
731
        }
-
 
732
 
-
 
733
        // All other users.
-
 
734
        if ($userid == 0 && $firstbrowseraccess) {
-
 
735
            $maxlifetime = $this->firstaccesstimeout;
-
 
736
        } else {
-
 
737
            // As per MDL-56823 - The following configures the session lifetime in redis to allow some
-
 
738
            // wriggle room in the user noticing they've been booted off and
-
 
739
            // letting them log back in before they lose their session entirely.
-
 
740
            $updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency;
494
     */
741
            $maxlifetime = (int) $CFG->sessiontimeout + $updatefreq + MINSECS;
-
 
742
        }
-
 
743
 
-
 
744
        return $maxlifetime;
-
 
745
    }
-
 
746
 
-
 
747
    /**
-
 
748
     * Connection will be null if these methods are called from cli or where NO_MOODLE_COOKIES is used.
-
 
749
     * We need to check for this and initialize the connection if required.
-
 
750
     *
-
 
751
     * @return void
-
 
752
     */
-
 
753
    private function init_redis_if_required(): void {
495
    // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameUnderscore
754
        if (is_null($this->connection)) {
Línea 496... Línea 755...
496
    public function gc(int $max_lifetime): int|false {
755
            $this->init();
497
        return false;
756
        }
498
    }
757
    }
499
 
758
 
500
    /**
759
    /**
501
     * Unlock a session.
760
     * Unlock a session.
502
     *
761
     *
503
     * @param string $id Session id to be unlocked.
762
     * @param string $id Session id to be unlocked.
504
     */
763
     */
505
    protected function unlock_session($id) {
764
    protected function unlock_session($id) {
506
        if (isset($this->locks[$id])) {
765
        if (isset($this->locks[$id])) {
Línea 507... Línea 766...
507
            $this->connection->del($id.".lock");
766
            $this->connection->unlink("{$id}.lock");
Línea 515... Línea 774...
515
     * @param string $id The session id to lock.
774
     * @param string $id The session id to lock.
516
     * @return bool true when session was locked, exception otherwise.
775
     * @return bool true when session was locked, exception otherwise.
517
     * @throws exception When we are unable to obtain a session lock.
776
     * @throws exception When we are unable to obtain a session lock.
518
     */
777
     */
519
    protected function lock_session($id) {
778
    protected function lock_session($id) {
520
        $lockkey = $id.".lock";
779
        $lockkey = "{$id}.lock";
-
 
780
 
-
 
781
        $haslock = isset($this->locks[$id]) && $this->clock->time() < $this->locks[$id];
-
 
782
        if ($haslock) {
-
 
783
            return true;
-
 
784
        }
Línea 521... Línea -...
521
 
-
 
522
        $haslock = isset($this->locks[$id]) && $this->time() < $this->locks[$id];
785
 
Línea 523... Línea 786...
523
        $startlocktime = $this->time();
786
        $startlocktime = $this->clock->time();
524
 
787
 
525
        /* To be able to ensure sessions don't write out of order we must obtain an exclusive lock
788
        // To be able to ensure sessions don't write out of order we must obtain an exclusive lock
526
         * on the session for the entire time it is open.  If another AJAX call, or page is using
-
 
Línea 527... Línea 789...
527
         * the session then we just wait until it finishes before we can open the session.
789
        // on the session for the entire time it is open.  If another AJAX call, or page is using
528
         */
790
        // the session then we just wait until it finishes before we can open the session.
529
 
791
 
530
        // Store the current host, process id and the request URI so it's easy to track who has the lock.
792
        // Store the current host, process id and the request URI so it's easy to track who has the lock.
531
        $hostname = gethostname();
793
        $hostname = gethostname();
-
 
794
        if ($hostname === false) {
532
        if ($hostname === false) {
795
            $hostname = 'UNKNOWN HOST';
533
            $hostname = 'UNKNOWN HOST';
796
        }
534
        }
797
 
535
        $pid = getmypid();
798
        $pid = getmypid();
-
 
799
        if ($pid === false) {
536
        if ($pid === false) {
800
            $pid = 'UNKNOWN';
Línea 537... Línea 801...
537
            $pid = 'UNKNOWN';
801
        }
Línea 538... Línea 802...
538
        }
802
 
Línea 539... Línea 803...
539
        $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown uri';
803
        $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown uri';
540
 
-
 
541
        $whoami = "[pid {$pid}] {$hostname}:$uri";
804
 
Línea 542... Línea 805...
542
 
805
        $whoami = "[pid {$pid}] {$hostname}:$uri";
543
        $haswarned = false; // Have we logged a lock warning?
806
 
544
 
-
 
545
        while (!$haslock) {
807
        $haswarned = false; // Have we logged a lock warning?
546
 
808
 
Línea 547... Línea 809...
547
            $haslock = $this->connection->setnx($lockkey, $whoami);
809
        while (!$haslock) {
548
 
810
            $haslock = $this->connection->set($lockkey, $whoami, ['nx', 'ex' => $this->lockexpire]);
549
            if ($haslock) {
811
 
550
                $this->locks[$id] = $this->time() + $this->lockexpire;
812
            if ($haslock) {
-
 
813
                $this->locks[$id] = $this->clock->time() + $this->lockexpire;
551
                $this->connection->expire($lockkey, $this->lockexpire);
814
                return true;
552
                return true;
815
            }
-
 
816
 
553
            }
817
            if (!empty($this->acquirewarn) && !$haswarned && $this->clock->time() > $startlocktime + $this->acquirewarn) {
554
 
818
                // This is a warning to better inform users.
Línea 555... Línea 819...
555
            if (!empty($this->acquirewarn) && !$haswarned && $this->time() > $startlocktime + $this->acquirewarn) {
819
                $whohaslock = $this->connection->get($lockkey);
556
                // This is a warning to better inform users.
820
                // phpcs:ignore
557
                $whohaslock = $this->connection->get($lockkey);
821
                error_log(
558
                // phpcs:ignore
822
                    "Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " .
559
                error_log("Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " .
823
                        "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.",
560
                    "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.");
824
                );
-
 
825
                $haswarned = true;
561
                $haswarned = true;
826
            }
562
            }
827
 
-
 
828
            if ($this->clock->time() > $startlocktime + $this->acquiretimeout) {
563
 
829
                // This is a fatal error, better inform users.
564
            if ($this->time() > $startlocktime + $this->acquiretimeout) {
830
                // It should not happen very often - all pages that need long time to execute
565
                // This is a fatal error, better inform users.
831
                // should close session immediately after access control checks.
566
                // It should not happen very often - all pages that need long time to execute
832
                $whohaslock = $this->connection->get($lockkey);
567
                // should close session immediately after access control checks.
833
                // phpcs:ignore
568
                $whohaslock = $this->connection->get($lockkey);
834
                error_log(
569
                // phpcs:ignore
835
                    "Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " .
-
 
836
                        "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.",
570
                error_log("Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " .
837
                );
571
                    "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.");
838
                $acquiretimeout = format_time($this->acquiretimeout);
Línea 572... Línea 839...
572
                $acquiretimeout = format_time($this->acquiretimeout);
839
                $lockexpire = format_time($this->lockexpire);
573
                $lockexpire = format_time($this->lockexpire);
840
                $a = (object)[
574
                $a = (object)[
841
                    'id' => substr($id, 0, 10),
575
                    'id' => substr($id, 0, 10),
842
                    'acquiretimeout' => $acquiretimeout,
576
                    'acquiretimeout' => $acquiretimeout,
843
                    'whohaslock' => $whohaslock,
577
                    'whohaslock' => $whohaslock,
844
                    'lockexpire' => $lockexpire,
Línea 593... Línea 860...
593
                $delay = rand(1000, 1100);
860
                $delay = rand(1000, 1100);
594
            }
861
            }
Línea 595... Línea 862...
595
 
862
 
596
            usleep($delay * 1000);
863
            usleep($delay * 1000);
-
 
864
        }
597
        }
865
        throw new coding_exception('Unable to lock session');
Línea 598... Línea -...
598
    }
-
 
599
 
-
 
600
    /**
-
 
601
     * Return the current time.
-
 
602
     *
-
 
603
     * @return int the current time as a unixtimestamp.
-
 
604
     */
866
    }
605
    protected function time() {
-
 
606
        return time();
-
 
607
    }
-
 
608
 
-
 
609
    /**
-
 
610
     * Check the backend contains data for this session id.
-
 
611
     *
-
 
612
     * Note: this is intended to be called from manager::session_exists() only.
-
 
613
     *
-
 
614
     * @param string $sid
-
 
615
     * @return bool true if session found.
867
 
616
     */
868
    #[\Override]
617
    public function session_exists($sid) {
869
    public function session_exists($sid) {
618
        if (!$this->connection) {
870
        if (!$this->connection) {
Línea 619... Línea 871...
619
            return false;
871
            return false;
-
 
872
        }
620
        }
873
 
621
 
874
        try {
622
        try {
875
            $sessionhashkey = $this->sessionkeyprefix . $sid;
623
            return !empty($this->connection->exists($sid));
876
            return !empty($this->connection->exists($sessionhashkey));
624
        } catch (RedisException | RedisClusterException $e) {
877
        } catch (RedisException | RedisClusterException $e) {
625
            return false;
-
 
626
        }
-
 
627
    }
-
 
628
 
-
 
629
    /**
-
 
630
     * Kill all active sessions, the core sessions table is purged afterwards.
-
 
631
     */
-
 
632
    public function kill_all_sessions() {
-
 
633
        global $DB;
-
 
634
        if (!$this->connection) {
-
 
635
            return;
-
 
636
        }
-
 
637
 
-
 
638
        $rs = $DB->get_recordset('sessions', array(), 'id DESC', 'id, sid');
-
 
639
        foreach ($rs as $record) {
-
 
640
            $this->destroy($record->sid);
-
 
641
        }
-
 
642
        $rs->close();
-
 
643
    }
-
 
644
 
-
 
645
    /**
-
 
646
     * Kill one session, the session record is removed afterwards.
-
 
647
     *
-
 
648
     * @param string $sid
-
 
649
     */
-
 
650
    public function kill_session($sid) {
-
 
651
        if (!$this->connection) {
-
 
652
            return;
-
 
653
        }
-
 
654
 
878
            return false;