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