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