Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
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/>.
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
 
25
namespace core\session;
26
 
27
use RedisException;
28
use RedisClusterException;
29
use SessionHandlerInterface;
30
 
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
36
 * https://github.com/php-memcached-dev/php-memcached/blob/master/php_memcached_session.c
37
 *
38
 * @package    core
39
 * @copyright  2016 Russell Smith
40
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41
 */
42
class redis extends handler implements SessionHandlerInterface {
43
    /**
44
     * Compressor: none.
45
     */
46
    const COMPRESSION_NONE      = 'none';
47
    /**
48
     * Compressor: PHP GZip.
49
     */
50
    const COMPRESSION_GZIP      = 'gzip';
51
    /**
52
     * Compressor: PHP Zstandard.
53
     */
54
    const COMPRESSION_ZSTD      = 'zstd';
55
 
56
    /** @var array $host save_path string  */
57
    protected array $host = [];
58
    /** @var int $port The port to connect to */
59
    protected $port = 6379;
60
    /** @var array $sslopts SSL options, if applicable */
61
    protected $sslopts = [];
62
    /** @var string $auth redis password  */
63
    protected $auth = '';
64
    /** @var int $database the Redis database to store sesions in */
65
    protected $database = 0;
66
    /** @var array $servers list of servers parsed from save_path */
67
    protected $prefix = '';
68
    /** @var int $acquiretimeout how long to wait for session lock in seconds */
69
    protected $acquiretimeout = 120;
70
    /** @var int $acquirewarn how long before warning when waiting for a lock in seconds */
71
    protected $acquirewarn = null;
72
    /** @var int $lockretry how long to wait between session lock attempts in ms */
73
    protected $lockretry = 100;
74
    /** @var int $serializer The serializer to use */
75
    protected $serializer = \Redis::SERIALIZER_PHP;
76
    /** @var int $compressor The compressor to use */
77
    protected $compressor = self::COMPRESSION_NONE;
78
    /** @var string $lasthash hash of the session data content */
79
    protected $lasthash = null;
80
 
81
    /**
82
     * @var int $lockexpire how long to wait in seconds before expiring the lock automatically
83
     * so that other requests may continue execution, ignored if PECL redis is below version 2.2.0.
84
     */
85
    protected $lockexpire;
86
 
87
    /** @var Redis|RedisCluster Connection */
88
    protected $connection = null;
89
 
90
    /** @var array $locks List of currently held locks by this page. */
91
    protected $locks = array();
92
 
93
    /** @var int $timeout How long sessions live before expiring. */
94
    protected $timeout;
95
 
96
    /** @var bool $clustermode Redis in cluster mode. */
97
    protected bool $clustermode = false;
98
 
99
    /** @var int Maximum number of retries for cache store operations. */
100
    const MAX_RETRIES = 5;
101
 
102
    /**
103
     * Create new instance of handler.
104
     */
105
    public function __construct() {
106
        global $CFG;
107
 
108
        if (isset($CFG->session_redis_host)) {
109
            // If there is only one host, use the single Redis connection.
110
            // If there are multiple hosts (separated by a comma), use the Redis cluster connection.
111
            $this->host = array_filter(array_map('trim', explode(',', $CFG->session_redis_host)));
112
            $this->clustermode = count($this->host) > 1;
113
        }
114
 
115
        if (isset($CFG->session_redis_port)) {
116
            $this->port = (int)$CFG->session_redis_port;
117
        }
118
 
119
        if (isset($CFG->session_redis_encrypt) && $CFG->session_redis_encrypt) {
120
            $this->sslopts = $CFG->session_redis_encrypt;
121
        }
122
 
123
        if (isset($CFG->session_redis_auth)) {
124
            $this->auth = $CFG->session_redis_auth;
125
        }
126
 
127
        if (isset($CFG->session_redis_database)) {
128
            $this->database = (int)$CFG->session_redis_database;
129
        }
130
 
131
        if (isset($CFG->session_redis_prefix)) {
132
            $this->prefix = $CFG->session_redis_prefix;
133
        }
134
 
135
        if (isset($CFG->session_redis_acquire_lock_timeout)) {
136
            $this->acquiretimeout = (int)$CFG->session_redis_acquire_lock_timeout;
137
        }
138
 
139
        if (isset($CFG->session_redis_acquire_lock_warn)) {
140
            $this->acquirewarn = (int)$CFG->session_redis_acquire_lock_warn;
141
        }
142
 
143
        if (isset($CFG->session_redis_acquire_lock_retry)) {
144
            $this->lockretry = (int)$CFG->session_redis_acquire_lock_retry;
145
        }
146
 
147
        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.
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;
156
 
157
        // This sets the Redis session lock expiry time to whatever is lower, either
158
        // the PHP execution time `max_execution_time`, if the value was defined in
159
        // the `php.ini` or the globally configured `sessiontimeout`. Setting it to
160
        // the lower of the two will not make things worse it if the execution timeout
161
        // is longer than the session timeout.
162
        // For the PHP execution time, once the PHP execution time is over, we can be sure
163
        // that the lock is no longer actively held so that the lock can expire safely.
164
        // 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
166
        // `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
168
        // once the session itself expires.
169
        // If we unnecessarily hold the lock any longer, it blocks other session requests.
170
        $this->lockexpire = ini_get('max_execution_time');
171
        if (empty($this->lockexpire) || ($this->lockexpire > (int)$CFG->sessiontimeout)) {
172
            $this->lockexpire = (int)$CFG->sessiontimeout;
173
        }
174
        if (isset($CFG->session_redis_lock_expire)) {
175
            $this->lockexpire = (int)$CFG->session_redis_lock_expire;
176
        }
177
 
178
        if (isset($CFG->session_redis_compressor)) {
179
            $this->compressor = $CFG->session_redis_compressor;
180
        }
181
    }
182
 
183
    /**
184
     * Start the session.
185
     *
186
     * @return bool success
187
     */
188
    public function start() {
189
        $result = parent::start();
190
 
191
        return $result;
192
    }
193
 
194
    /**
195
     * Init session handler.
196
     */
197
    public function init() {
198
        if (!extension_loaded('redis')) {
199
            throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension is not loaded');
200
        }
201
 
202
        if (empty($this->host)) {
203
            throw new exception('sessionhandlerproblem', 'error', '', null,
204
                    '$CFG->session_redis_host must be specified in config.php');
205
        }
206
 
207
        // The session handler requires a version of Redis with the SETEX command (at least 2.0).
208
        $version = phpversion('Redis');
209
        if (!$version || version_compare($version, '2.0') <= 0) {
210
            throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension version must be at least 2.0');
211
        }
212
 
213
        $result = session_set_save_handler($this);
214
        if (!$result) {
215
            throw new exception('redissessionhandlerproblem', 'error');
216
        }
217
 
218
        $encrypt = (bool) ($this->sslopts ?? false);
219
        // Set Redis server(s).
220
        $trimmedservers = [];
221
        foreach ($this->host as $host) {
222
            $server = strtolower(trim($host));
223
            if (!empty($server)) {
224
                if ($server[0] === '/' || str_starts_with($server, 'unix://')) {
225
                    $port = 0;
226
                    $trimmedservers[] = $server;
227
                } else {
228
                    $port = $this->port ?? 6379; // No Unix socket so set default port.
229
                    if (strpos($server, ':')) { // Check for custom port.
230
                        list($server, $port) = explode(':', $server);
231
                    }
232
                    $trimmedservers[] = $server.':'.$port;
233
                }
234
 
235
                // We only need the first record for the single redis.
236
                if (!$this->clustermode) {
237
                    // Handle the case when the server is not a Unix domain socket.
238
                    if ($port !== 0) {
239
                        list($server, ) = explode(':', $trimmedservers[0]);
240
                    } else {
241
                        $server = $trimmedservers[0];
242
                    }
243
                    break;
244
                }
245
            }
246
        }
247
 
248
        // TLS/SSL Configuration.
249
        $opts = [];
250
        if ($encrypt) {
251
            if ($this->clustermode) {
252
                $opts = $this->sslopts;
253
            } else {
254
                // For a single (non-cluster) Redis, the TLS/SSL config must be added to the 'stream' key.
255
                $opts['stream'] = $this->sslopts;
256
            }
257
        }
258
 
259
        // MDL-59866: Add retries for connections (up to 5 times) to make sure it goes through.
260
        $counter = 1;
261
        $exceptionclass = $this->clustermode ? 'RedisClusterException' : 'RedisException';
262
        while ($counter <= self::MAX_RETRIES) {
263
            $this->connection = null;
264
            // Make a connection to Redis server(s).
265
            try {
266
                // Create a $redis object of a RedisCluster or Redis class.
267
                if ($this->clustermode) {
268
                    $this->connection = new \RedisCluster(null, $trimmedservers, 1, 1, true,
269
                        $this->auth, !empty($opts) ? $opts : null);
270
                } else {
271
                    $delay = rand(100, 500);
272
                    $this->connection = new \Redis();
273
                    $this->connection->connect($server, $port, 1, null, $delay, 1, $opts);
274
                    if ($this->auth !== '' && !$this->connection->auth($this->auth)) {
275
                        throw new $exceptionclass('Unable to authenticate.');
276
                    }
277
                }
278
 
279
                if (!$this->connection->setOption(\Redis::OPT_SERIALIZER, $this->serializer)) {
280
                    throw new $exceptionclass('Unable to set the Redis PHP Serializer option.');
281
                }
282
                if ($this->prefix !== '') {
283
                    // Use custom prefix on sessions.
284
                    if (!$this->connection->setOption(\Redis::OPT_PREFIX, $this->prefix)) {
285
                        throw new $exceptionclass('Unable to set the Redis Prefix option.');
286
                    }
287
                }
288
                if ($this->sslopts && !$this->connection->ping('Ping')) {
289
                    // In case of a TLS connection,
290
                    // if phpredis client does not communicate immediately with the server the connection hangs.
291
                    // See https://github.com/phpredis/phpredis/issues/2332.
292
                    throw new $exceptionclass("Ping failed");
293
                }
294
                if ($this->database !== 0) {
295
                    if (!$this->connection->select($this->database)) {
296
                        throw new $exceptionclass('Unable to select the Redis database ' . $this->database . '.');
297
                    }
298
                }
299
                return true;
300
            } catch (RedisException | RedisClusterException $e) {
301
                $redishost = $this->clustermode ? implode(',', $this->host) : $server. ':'. $port;
302
                $logstring = "Failed to connect (try {$counter} out of " . self::MAX_RETRIES . ") to Redis ";
303
                $logstring .= "at ". $redishost .", the error returned was: {$e->getMessage()}";
304
                debugging($logstring);
305
            }
306
            $counter++;
307
            // Introduce a random sleep between 100ms and 500ms.
308
            usleep(rand(100000, 500000));
309
        }
310
 
311
        if (isset($logstring)) {
312
            // We have exhausted our retries; it's time to give up.
313
            throw new $exceptionclass($logstring);
314
        }
315
 
316
        $result = session_set_save_handler($this);
317
        if (!$result) {
318
            throw new exception('redissessionhandlerproblem', 'error');
319
        }
320
    }
321
 
322
    /**
323
     * Update our session search path to include session name when opened.
324
     *
325
     * @param string $path  unused session save path. (ignored)
326
     * @param string $name Session name for this session. (ignored)
327
     * @return bool true always as we will succeed.
328
     */
329
    public function open(string $path, string $name): bool {
330
        return true;
331
    }
332
 
333
    /**
334
     * Close the session completely. We also remove all locks we may have obtained that aren't expired.
335
     *
336
     * @return bool true on success.  false on unable to unlock sessions.
337
     */
338
    public function close(): bool {
339
        $this->lasthash = null;
340
        try {
341
            foreach ($this->locks as $id => $expirytime) {
342
                if ($expirytime > $this->time()) {
343
                    $this->unlock_session($id);
344
                }
345
                unset($this->locks[$id]);
346
            }
347
        } catch (RedisException | RedisClusterException $e) {
348
            error_log('Failed talking to redis: '.$e->getMessage());
349
            return false;
350
        }
351
 
352
        return true;
353
    }
354
 
355
    /**
356
     * Read the session data from storage
357
     *
358
     * @param string $id The session id to read from storage.
359
     * @return string The session data for PHP to process.
360
     *
361
     * @throws RedisException when we are unable to talk to the Redis server.
362
     */
363
    public function read(string $id): string|false {
364
        try {
365
            if ($this->requires_write_lock()) {
366
                $this->lock_session($id);
367
            }
368
            $sessiondata = $this->uncompress($this->connection->get($id));
369
 
370
            if ($sessiondata === false) {
371
                if ($this->requires_write_lock()) {
372
                    $this->unlock_session($id);
373
                }
374
                $this->lasthash = sha1('');
375
                return '';
376
            }
377
            $this->connection->expire($id, $this->timeout);
378
        } catch (RedisException | RedisClusterException $e) {
379
            error_log('Failed talking to redis: '.$e->getMessage());
380
            throw $e;
381
        }
382
        $this->lasthash = sha1(base64_encode($sessiondata));
383
        return $sessiondata;
384
    }
385
 
386
    /**
387
     * Compresses session data.
388
     *
389
     * @param mixed $value
390
     * @return string
391
     */
392
    private function compress($value) {
393
        switch ($this->compressor) {
394
            case self::COMPRESSION_NONE:
395
                return $value;
396
            case self::COMPRESSION_GZIP:
397
                return gzencode($value);
398
            case self::COMPRESSION_ZSTD:
399
                return zstd_compress($value);
400
            default:
401
                debugging("Invalid compressor: {$this->compressor}");
402
                return $value;
403
        }
404
    }
405
 
406
    /**
407
     * Uncompresses session data.
408
     *
409
     * @param string $value
410
     * @return mixed
411
     */
412
    private function uncompress($value) {
413
        if ($value === false) {
414
            return false;
415
        }
416
 
417
        switch ($this->compressor) {
418
            case self::COMPRESSION_NONE:
419
                break;
420
            case self::COMPRESSION_GZIP:
421
                $value = gzdecode($value);
422
                break;
423
            case self::COMPRESSION_ZSTD:
424
                $value = zstd_uncompress($value);
425
                break;
426
            default:
427
                debugging("Invalid compressor: {$this->compressor}");
428
        }
429
 
430
        return $value;
431
    }
432
 
433
    /**
434
     * Write the serialized session data to our session store.
435
     *
436
     * @param string $id session id to write.
437
     * @param string $data session data
438
     * @return bool true on write success, false on failure
439
     */
440
    public function write(string $id, string $data): bool {
441
 
442
        $hash = sha1(base64_encode($data));
443
 
444
        // If the content has not changed don't bother writing.
445
        if ($hash === $this->lasthash) {
446
            return true;
447
        }
448
 
449
        if (is_null($this->connection)) {
450
            // The session has already been closed, don't attempt another write.
451
            error_log('Tried to write session: '.$id.' before open or after close.');
452
            return false;
453
        }
454
 
455
        // We do not do locking here because memcached doesn't.  Also
456
        // 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
458
        // address that in the future.
459
        try {
460
            $data = $this->compress($data);
461
 
462
            $this->connection->setex($id, $this->timeout, $data);
463
        } catch (RedisException | RedisClusterException $e) {
464
            error_log('Failed talking to redis: '.$e->getMessage());
465
            return false;
466
        }
467
        return true;
468
    }
469
 
470
    /**
471
     * Handle destroying a session.
472
     *
473
     * @param string $id the session id to destroy.
474
     * @return bool true if the session was deleted, false otherwise.
475
     */
476
    public function destroy(string $id): bool {
477
        $this->lasthash = null;
478
        try {
479
            $this->connection->del($id);
480
            $this->unlock_session($id);
481
        } catch (RedisException | RedisClusterException $e) {
482
            error_log('Failed talking to redis: '.$e->getMessage());
483
            return false;
484
        }
485
 
486
        return true;
487
    }
488
 
489
    /**
490
     * Garbage collect sessions.  We don't we any as Redis does it for us.
491
     *
492
     * @param integer $max_lifetime All sessions older than this should be removed.
493
     * @return bool true, as Redis handles expiry for us.
494
     */
495
    // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameUnderscore
496
    public function gc(int $max_lifetime): int|false {
497
        return false;
498
    }
499
 
500
    /**
501
     * Unlock a session.
502
     *
503
     * @param string $id Session id to be unlocked.
504
     */
505
    protected function unlock_session($id) {
506
        if (isset($this->locks[$id])) {
507
            $this->connection->del($id.".lock");
508
            unset($this->locks[$id]);
509
        }
510
    }
511
 
512
    /**
513
     * Obtain a session lock so we are the only one using it at the moment.
514
     *
515
     * @param string $id The session id to lock.
516
     * @return bool true when session was locked, exception otherwise.
517
     * @throws exception When we are unable to obtain a session lock.
518
     */
519
    protected function lock_session($id) {
520
        $lockkey = $id.".lock";
521
 
522
        $haslock = isset($this->locks[$id]) && $this->time() < $this->locks[$id];
523
        $startlocktime = $this->time();
524
 
525
        /* 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
527
         * the session then we just wait until it finishes before we can open the session.
528
         */
529
 
530
        // Store the current host, process id and the request URI so it's easy to track who has the lock.
531
        $hostname = gethostname();
532
        if ($hostname === false) {
533
            $hostname = 'UNKNOWN HOST';
534
        }
535
        $pid = getmypid();
536
        if ($pid === false) {
537
            $pid = 'UNKNOWN';
538
        }
539
        $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown uri';
540
 
541
        $whoami = "[pid {$pid}] {$hostname}:$uri";
542
 
543
        $haswarned = false; // Have we logged a lock warning?
544
 
545
        while (!$haslock) {
546
 
547
            $haslock = $this->connection->setnx($lockkey, $whoami);
548
 
549
            if ($haslock) {
550
                $this->locks[$id] = $this->time() + $this->lockexpire;
551
                $this->connection->expire($lockkey, $this->lockexpire);
552
                return true;
553
            }
554
 
555
            if (!empty($this->acquirewarn) && !$haswarned && $this->time() > $startlocktime + $this->acquirewarn) {
556
                // This is a warning to better inform users.
557
                $whohaslock = $this->connection->get($lockkey);
558
                // phpcs:ignore
559
                error_log("Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " .
560
                    "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.");
561
                $haswarned = true;
562
            }
563
 
564
            if ($this->time() > $startlocktime + $this->acquiretimeout) {
565
                // This is a fatal error, better inform users.
566
                // It should not happen very often - all pages that need long time to execute
567
                // should close session immediately after access control checks.
568
                $whohaslock = $this->connection->get($lockkey);
569
                // phpcs:ignore
570
                error_log("Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " .
571
                    "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.");
572
                $acquiretimeout = format_time($this->acquiretimeout);
573
                $lockexpire = format_time($this->lockexpire);
574
                $a = (object)[
575
                    'id' => substr($id, 0, 10),
576
                    'acquiretimeout' => $acquiretimeout,
577
                    'whohaslock' => $whohaslock,
578
                    'lockexpire' => $lockexpire];
579
                throw new exception("sessioncannotobtainlock", 'error', '', $a);
580
            }
581
 
582
            if ($this->time() < $startlocktime + 5) {
583
                // We want a random delay to stagger the polling load. Ideally
584
                // this delay should be a fraction of the average response
585
                // time. If it is too small we will poll too much and if it is
586
                // too large we will waste time waiting for no reason. 100ms is
587
                // the default starting point.
588
                $delay = rand($this->lockretry, (int)($this->lockretry * 1.1));
589
            } else {
590
                // If we don't get a lock within 5 seconds then there must be a
591
                // very long lived process holding the lock so throttle back to
592
                // just polling roughly once a second.
593
                $delay = rand(1000, 1100);
594
            }
595
 
596
            usleep($delay * 1000);
597
        }
598
    }
599
 
600
    /**
601
     * Return the current time.
602
     *
603
     * @return int the current time as a unixtimestamp.
604
     */
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.
616
     */
617
    public function session_exists($sid) {
618
        if (!$this->connection) {
619
            return false;
620
        }
621
 
622
        try {
623
            return !empty($this->connection->exists($sid));
624
        } 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
 
655
        $this->destroy($sid);
656
    }
657
}