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