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