Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403] [Versions 402 and 403]
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 29 defined('MOODLE_INTERNAL') || die(); 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 { 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 string $host save_path string */ 57 protected $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 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 /** 97 * Create new instance of handler. 98 */ 99 public function __construct() { 100 global $CFG; 101 102 if (isset($CFG->session_redis_host)) { 103 $this->host = $CFG->session_redis_host; 104 } 105 106 if (isset($CFG->session_redis_port)) { 107 $this->port = (int)$CFG->session_redis_port; 108 } 109 110 if (isset($CFG->session_redis_encrypt) && $CFG->session_redis_encrypt) { 111 $this->host = 'tls://' . $this->host; 112 $this->sslopts = $CFG->session_redis_encrypt; 113 } 114 115 if (isset($CFG->session_redis_auth)) { 116 $this->auth = $CFG->session_redis_auth; 117 } 118 119 if (isset($CFG->session_redis_database)) { 120 $this->database = (int)$CFG->session_redis_database; 121 } 122 123 if (isset($CFG->session_redis_prefix)) { 124 $this->prefix = $CFG->session_redis_prefix; 125 } 126 127 if (isset($CFG->session_redis_acquire_lock_timeout)) { 128 $this->acquiretimeout = (int)$CFG->session_redis_acquire_lock_timeout; 129 } 130 131 if (isset($CFG->session_redis_acquire_lock_warn)) { 132 $this->acquirewarn = (int)$CFG->session_redis_acquire_lock_warn; 133 } 134 135 if (isset($CFG->session_redis_acquire_lock_retry)) { 136 $this->lockretry = (int)$CFG->session_redis_acquire_lock_retry; 137 } 138 139 if (!empty($CFG->session_redis_serializer_use_igbinary) && defined('\Redis::SERIALIZER_IGBINARY')) { 140 $this->serializer = \Redis::SERIALIZER_IGBINARY; // Set igbinary serializer if phpredis supports it. 141 } 142 143 // The following configures the session lifetime in redis to allow some 144 // wriggle room in the user noticing they've been booted off and 145 // letting them log back in before they lose their session entirely. 146 $updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency; 147 $this->timeout = $CFG->sessiontimeout + $updatefreq + MINSECS; 148 149 // This sets the Redis session lock expiry time to whatever is lower, either 150 // the PHP execution time `max_execution_time`, if the value was defined in 151 // the `php.ini` or the globally configured `sessiontimeout`. Setting it to 152 // the lower of the two will not make things worse it if the execution timeout 153 // is longer than the session timeout. 154 // For the PHP execution time, once the PHP execution time is over, we can be sure 155 // that the lock is no longer actively held so that the lock can expire safely. 156 // Although at `lib/classes/php_time_limit.php::raise(int)`, Moodle can 157 // progressively increase the maximum PHP execution time, this is limited to the 158 // `max_execution_time` value defined in the `php.ini`. 159 // For the session timeout, we assume it is safe to consider the lock to expire 160 // once the session itself expires. 161 // If we unnecessarily hold the lock any longer, it blocks other session requests. 162 $this->lockexpire = ini_get('max_execution_time'); 163 if (empty($this->lockexpire) || ($this->lockexpire > (int)$CFG->sessiontimeout)) { 164 $this->lockexpire = (int)$CFG->sessiontimeout; 165 } 166 if (isset($CFG->session_redis_lock_expire)) { 167 $this->lockexpire = (int)$CFG->session_redis_lock_expire; 168 } 169 170 if (isset($CFG->session_redis_compressor)) { 171 $this->compressor = $CFG->session_redis_compressor; 172 } 173 } 174 175 /** 176 * Start the session. 177 * 178 * @return bool success 179 */ 180 public function start() { 181 $result = parent::start(); 182 183 return $result; 184 } 185 186 /** 187 * Init session handler. 188 */ 189 public function init() { 190 if (!extension_loaded('redis')) { 191 throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension is not loaded'); 192 } 193 194 if (empty($this->host)) { 195 throw new exception('sessionhandlerproblem', 'error', '', null, 196 '$CFG->session_redis_host must be specified in config.php'); 197 } 198 199 // The session handler requires a version of Redis with the SETEX command (at least 2.0). 200 $version = phpversion('Redis'); 201 if (!$version or version_compare($version, '2.0') <= 0) { 202 throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension version must be at least 2.0'); 203 } 204 205 $this->connection = new \Redis(); 206 207 $result = session_set_save_handler(array($this, 'handler_open'), 208 array($this, 'handler_close'), 209 array($this, 'handler_read'), 210 array($this, 'handler_write'), 211 array($this, 'handler_destroy'), 212 array($this, 'handler_gc')); 213 if (!$result) { 214 throw new exception('redissessionhandlerproblem', 'error'); 215 } 216 217 // MDL-59866: Add retries for connections (up to 5 times) to make sure it goes through. 218 $counter = 1; 219 $maxnumberofretries = 5; 220 $opts = []; 221 if ($this->sslopts) { 222 // Do not set $opts['stream'] = [], breaks connect(). 223 $opts['stream'] = $this->sslopts; 224 } 225 226 while ($counter <= $maxnumberofretries) { 227 228 try { 229 230 $delay = rand(100, 500); 231 232 // One second timeout was chosen as it is long for connection, but short enough for a user to be patient. 233 if (!$this->connection->connect($this->host, $this->port, 1, null, $delay, 1, $opts)) { 234 throw new RedisException('Unable to connect to host.'); 235 } 236 237 if ($this->auth !== '') { 238 if (!$this->connection->auth($this->auth)) { 239 throw new RedisException('Unable to authenticate.'); 240 } 241 } 242 243 if (!$this->connection->setOption(\Redis::OPT_SERIALIZER, $this->serializer)) { 244 throw new RedisException('Unable to set Redis PHP Serializer option.'); 245 } 246 247 if ($this->prefix !== '') { 248 // Use custom prefix on sessions. 249 if (!$this->connection->setOption(\Redis::OPT_PREFIX, $this->prefix)) { 250 throw new RedisException('Unable to set Redis Prefix option.'); 251 } 252 } 253 254 if ($this->sslopts && !$this->connection->ping()) { 255 /* 256 * In case of a TLS connection, if phpredis client does not 257 * communicate immediately with the server the connection hangs. 258 * See https://github.com/phpredis/phpredis/issues/2332 . 259 */ 260 throw new \RedisException("Ping failed"); 261 } 262 263 if ($this->database !== 0) { 264 if (!$this->connection->select($this->database)) { 265 throw new RedisException('Unable to select Redis database '.$this->database.'.'); 266 } 267 } 268 return true; 269 } catch (RedisException $e) { 270 $logstring = "Failed to connect (try {$counter} out of {$maxnumberofretries}) to redis "; 271 $logstring .= "at {$this->host}:{$this->port}, error returned was: {$e->getMessage()}"; 272 273 debugging($logstring); 274 } 275 276 $counter++; 277 278 // Introduce a random sleep between 100ms and 500ms. 279 usleep(rand(100000, 500000)); 280 } 281 282 // We have exhausted our retries, time to give up. 283 if (isset($logstring)) { 284 throw new RedisException($logstring); 285 } 286 } 287 288 /** 289 * Update our session search path to include session name when opened. 290 * 291 * @param string $savepath unused session save path. (ignored) 292 * @param string $sessionname Session name for this session. (ignored) 293 * @return bool true always as we will succeed. 294 */ 295 public function handler_open($savepath, $sessionname) { 296 return true; 297 } 298 299 /** 300 * Close the session completely. We also remove all locks we may have obtained that aren't expired. 301 * 302 * @return bool true on success. false on unable to unlock sessions. 303 */ 304 public function handler_close() { 305 $this->lasthash = null; 306 try { 307 foreach ($this->locks as $id => $expirytime) { 308 if ($expirytime > $this->time()) { 309 $this->unlock_session($id); 310 } 311 unset($this->locks[$id]); 312 } 313 } catch (RedisException $e) { 314 error_log('Failed talking to redis: '.$e->getMessage()); 315 return false; 316 } 317 318 return true; 319 } 320 /** 321 * Read the session data from storage 322 * 323 * @param string $id The session id to read from storage. 324 * @return string The session data for PHP to process. 325 * 326 * @throws RedisException when we are unable to talk to the Redis server. 327 */ 328 public function handler_read($id) { 329 try { 330 if ($this->requires_write_lock()) { 331 $this->lock_session($id); 332 } 333 $sessiondata = $this->uncompress($this->connection->get($id)); 334 335 if ($sessiondata === false) { 336 if ($this->requires_write_lock()) { 337 $this->unlock_session($id); 338 } 339 $this->lasthash = sha1(''); 340 return ''; 341 } 342 $this->connection->expire($id, $this->timeout); 343 } catch (RedisException $e) { 344 error_log('Failed talking to redis: '.$e->getMessage()); 345 throw $e; 346 } 347 $this->lasthash = sha1(base64_encode($sessiondata)); 348 return $sessiondata; 349 } 350 351 /** 352 * Compresses session data. 353 * 354 * @param mixed $value 355 * @return string 356 */ 357 private function compress($value) { 358 switch ($this->compressor) { 359 case self::COMPRESSION_NONE: 360 return $value; 361 case self::COMPRESSION_GZIP: 362 return gzencode($value); 363 case self::COMPRESSION_ZSTD: 364 return zstd_compress($value); 365 default: 366 debugging("Invalid compressor: {$this->compressor}"); 367 return $value; 368 } 369 } 370 371 /** 372 * Uncompresses session data. 373 * 374 * @param string $value 375 * @return mixed 376 */ 377 private function uncompress($value) { 378 if ($value === false) { 379 return false; 380 } 381 382 switch ($this->compressor) { 383 case self::COMPRESSION_NONE: 384 break; 385 case self::COMPRESSION_GZIP: 386 $value = gzdecode($value); 387 break; 388 case self::COMPRESSION_ZSTD: 389 $value = zstd_uncompress($value); 390 break; 391 default: 392 debugging("Invalid compressor: {$this->compressor}"); 393 } 394 395 return $value; 396 } 397 398 /** 399 * Write the serialized session data to our session store. 400 * 401 * @param string $id session id to write. 402 * @param string $data session data 403 * @return bool true on write success, false on failure 404 */ 405 public function handler_write($id, $data) { 406 407 $hash = sha1(base64_encode($data)); 408 409 // If the content has not changed don't bother writing. 410 if ($hash === $this->lasthash) { 411 return true; 412 } 413 414 if (is_null($this->connection)) { 415 // The session has already been closed, don't attempt another write. 416 error_log('Tried to write session: '.$id.' before open or after close.'); 417 return false; 418 } 419 420 // We do not do locking here because memcached doesn't. Also 421 // PHP does open, read, destroy, write, close. When a session doesn't exist. 422 // There can be race conditions on new sessions racing each other but we can 423 // address that in the future. 424 try { 425 $data = $this->compress($data); 426 427 $this->connection->setex($id, $this->timeout, $data); 428 } catch (RedisException $e) { 429 error_log('Failed talking to redis: '.$e->getMessage()); 430 return false; 431 } 432 return true; 433 } 434 435 /** 436 * Handle destroying a session. 437 * 438 * @param string $id the session id to destroy. 439 * @return bool true if the session was deleted, false otherwise. 440 */ 441 public function handler_destroy($id) { 442 $this->lasthash = null; 443 try { 444 $this->connection->del($id); 445 $this->unlock_session($id); 446 } catch (RedisException $e) { 447 error_log('Failed talking to redis: '.$e->getMessage()); 448 return false; 449 } 450 451 return true; 452 } 453 454 /** 455 * Garbage collect sessions. We don't we any as Redis does it for us. 456 * 457 * @param integer $maxlifetime All sessions older than this should be removed. 458 * @return bool true, as Redis handles expiry for us. 459 */ 460 public function handler_gc($maxlifetime) { 461 return true; 462 } 463 464 /** 465 * Unlock a session. 466 * 467 * @param string $id Session id to be unlocked. 468 */ 469 protected function unlock_session($id) { 470 if (isset($this->locks[$id])) { 471 $this->connection->del($id.".lock"); 472 unset($this->locks[$id]); 473 } 474 } 475 476 /** 477 * Obtain a session lock so we are the only one using it at the moment. 478 * 479 * @param string $id The session id to lock. 480 * @return bool true when session was locked, exception otherwise. 481 * @throws exception When we are unable to obtain a session lock. 482 */ 483 protected function lock_session($id) { 484 $lockkey = $id.".lock"; 485 486 $haslock = isset($this->locks[$id]) && $this->time() < $this->locks[$id]; 487 $startlocktime = $this->time(); 488 489 /* To be able to ensure sessions don't write out of order we must obtain an exclusive lock 490 * on the session for the entire time it is open. If another AJAX call, or page is using 491 * the session then we just wait until it finishes before we can open the session. 492 */ 493 494 // Store the current host, process id and the request URI so it's easy to track who has the lock. 495 $hostname = gethostname(); 496 if ($hostname === false) { 497 $hostname = 'UNKNOWN HOST'; 498 } 499 $pid = getmypid(); 500 if ($pid === false) { 501 $pid = 'UNKNOWN'; 502 } 503 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown uri'; 504 505 $whoami = "[pid {$pid}] {$hostname}:$uri"; 506 507 $haswarned = false; // Have we logged a lock warning? 508 509 while (!$haslock) { 510 511 $haslock = $this->connection->setnx($lockkey, $whoami); 512 513 if ($haslock) { 514 $this->locks[$id] = $this->time() + $this->lockexpire; 515 $this->connection->expire($lockkey, $this->lockexpire); 516 return true; 517 } 518 519 if (!empty($this->acquirewarn) && !$haswarned && $this->time() > $startlocktime + $this->acquirewarn) { 520 // This is a warning to better inform users. 521 $whohaslock = $this->connection->get($lockkey); 522 // phpcs:ignore 523 error_log("Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " . 524 "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released."); 525 $haswarned = true; 526 } 527 528 if ($this->time() > $startlocktime + $this->acquiretimeout) { 529 // This is a fatal error, better inform users. 530 // It should not happen very often - all pages that need long time to execute 531 // should close session immediately after access control checks. 532 $whohaslock = $this->connection->get($lockkey); 533 // phpcs:ignore 534 error_log("Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " . 535 "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released."); 536 $acquiretimeout = format_time($this->acquiretimeout); 537 $lockexpire = format_time($this->lockexpire); 538 $a = (object)[ 539 'id' => substr($id, 0, 10), 540 'acquiretimeout' => $acquiretimeout, 541 'whohaslock' => $whohaslock, 542 'lockexpire' => $lockexpire]; 543 throw new exception("sessioncannotobtainlock", 'error', '', $a); 544 } 545 546 if ($this->time() < $startlocktime + 5) { 547 // We want a random delay to stagger the polling load. Ideally 548 // this delay should be a fraction of the average response 549 // time. If it is too small we will poll too much and if it is 550 // too large we will waste time waiting for no reason. 100ms is 551 // the default starting point. 552 $delay = rand($this->lockretry, (int)($this->lockretry * 1.1)); 553 } else { 554 // If we don't get a lock within 5 seconds then there must be a 555 // very long lived process holding the lock so throttle back to 556 // just polling roughly once a second. 557 $delay = rand(1000, 1100); 558 } 559 560 usleep($delay * 1000); 561 } 562 } 563 564 /** 565 * Return the current time. 566 * 567 * @return int the current time as a unixtimestamp. 568 */ 569 protected function time() { 570 return time(); 571 } 572 573 /** 574 * Check the backend contains data for this session id. 575 * 576 * Note: this is intended to be called from manager::session_exists() only. 577 * 578 * @param string $sid 579 * @return bool true if session found. 580 */ 581 public function session_exists($sid) { 582 if (!$this->connection) { 583 return false; 584 } 585 586 try { 587 return !empty($this->connection->exists($sid)); 588 } catch (RedisException $e) { 589 return false; 590 } 591 } 592 593 /** 594 * Kill all active sessions, the core sessions table is purged afterwards. 595 */ 596 public function kill_all_sessions() { 597 global $DB; 598 if (!$this->connection) { 599 return; 600 } 601 602 $rs = $DB->get_recordset('sessions', array(), 'id DESC', 'id, sid'); 603 foreach ($rs as $record) { 604 $this->handler_destroy($record->sid); 605 } 606 $rs->close(); 607 } 608 609 /** 610 * Kill one session, the session record is removed afterwards. 611 * 612 * @param string $sid 613 */ 614 public function kill_session($sid) { 615 if (!$this->connection) { 616 return; 617 } 618 619 $this->handler_destroy($sid); 620 } 621 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body