Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 311 and 400] [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   * Session manager class.
  19   *
  20   * @package    core
  21   * @copyright  2013 Petr Skoda {@link http://skodak.org}
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core\session;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  use html_writer;
  30  
  31  /**
  32   * Session manager, this is the public Moodle API for sessions.
  33   *
  34   * Following PHP functions MUST NOT be used directly:
  35   * - session_start() - not necessary, lib/setup.php starts session automatically,
  36   *   use define('NO_MOODLE_COOKIE', true) if session not necessary.
  37   * - session_write_close() - use \core\session\manager::write_close() instead.
  38   * - session_destroy() - use require_logout() instead.
  39   *
  40   * @package    core
  41   * @copyright  2013 Petr Skoda {@link http://skodak.org}
  42   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  43   */
  44  class manager {
  45      /** @var int A hard cutoff of maximum stored history */
  46      const MAXIMUM_STORED_SESSION_HISTORY = 50;
  47  
  48      /** @var int The recent session locks array is reset if there is a time gap more than this value in seconds */
  49      const SESSION_RESET_GAP_THRESHOLD = 1;
  50  
  51      /** @var handler $handler active session handler instance */
  52      protected static $handler;
  53  
  54      /** @var bool $sessionactive Is the session active? */
  55      protected static $sessionactive = null;
  56  
  57      /** @var string $logintokenkey Key used to get and store request protection for login form. */
  58      protected static $logintokenkey = 'core_auth_login';
  59  
  60      /** @var array Stores the the SESSION before a request is performed, used to check incorrect read-only modes */
  61      private static $priorsession = [];
  62  
  63      /**
  64       * @var bool Used to trigger the SESSION mutation warning without actually preventing SESSION mutation.
  65       * This variable is used to "copy" what the $requireslock parameter  does in start_session().
  66       * Once requireslock is set in start_session it's later accessible via $handler->requires_write_lock,
  67       * When using $CFG->enable_read_only_sessions_debug mode, this variable serves the same purpose without
  68       * actually setting the handler as requiring a write lock.
  69       */
  70      private static $requireslockdebug;
  71  
  72      /**
  73       * If the current session is not writeable, abort it, and re-open it
  74       * requesting (and blocking) until a write lock is acquired.
  75       * If current session was already opened with an intentional write lock,
  76       * this call will not do anything.
  77       * NOTE: Even when using a session handler that does not support non-locking sessions,
  78       * if the original session was not opened with the explicit intention of being locked,
  79       * this will still restart your session so that code behaviour matches as closely
  80       * as practical across environments.
  81       *
  82       * @param bool $readonlysession Used by debugging logic to determine if whatever
  83       *                              triggered the restart (e.g., a webservice) declared
  84       *                              itself as read only.
  85       */
  86      public static function restart_with_write_lock(bool $readonlysession) {
  87          global $CFG;
  88  
  89          self::$requireslockdebug = !$readonlysession;
  90  
  91          if (self::$sessionactive && !self::$handler->requires_write_lock()) {
  92              @self::$handler->abort();
  93              self::$sessionactive = false;
  94              self::start_session(true);
  95          }
  96      }
  97  
  98      /**
  99       * Start user session.
 100       *
 101       * Note: This is intended to be called only from lib/setup.php!
 102       */
 103      public static function start() {
 104          global $CFG, $DB, $PERF;
 105  
 106          if (isset(self::$sessionactive)) {
 107              debugging('Session was already started!', DEBUG_DEVELOPER);
 108              return;
 109          }
 110  
 111          // Grab the time before session lock starts.
 112          $PERF->sessionlock['start'] = microtime(true);
 113          self::load_handler();
 114  
 115          // Init the session handler only if everything initialised properly in lib/setup.php file
 116          // and the session is actually required.
 117          if (empty($DB) or empty($CFG->version) or !defined('NO_MOODLE_COOKIES') or NO_MOODLE_COOKIES or CLI_SCRIPT) {
 118              self::$sessionactive = false;
 119              self::init_empty_session();
 120              return;
 121          }
 122  
 123          if (defined('READ_ONLY_SESSION') && !empty($CFG->enable_read_only_sessions)) {
 124              $requireslock = !READ_ONLY_SESSION;
 125          } else {
 126              $requireslock = true; // For backwards compatibility, we default to assuming that a lock is needed.
 127          }
 128  
 129          // By default make the two variables the same. This means that when they are
 130          // checked below in start_session and write_close there is no possibility for
 131          // the debug version to "accidentally" execute the debug mode.
 132          self::$requireslockdebug = $requireslock;
 133          if (defined('READ_ONLY_SESSION') && !empty($CFG->enable_read_only_sessions_debug)) {
 134              // Only change the debug variable if READ_ONLY_SESSION is declared,
 135              // as would happen with the real requireslock variable.
 136              self::$requireslockdebug = !READ_ONLY_SESSION;
 137          }
 138  
 139          self::start_session($requireslock);
 140      }
 141  
 142      /**
 143       * Handles starting a session.
 144       *
 145       * @param bool $requireslock If this is false then no write lock will be acquired,
 146       *                           and the session will be read-only.
 147       */
 148      private static function start_session(bool $requireslock) {
 149          global $PERF, $CFG;
 150  
 151          try {
 152              self::$handler->init();
 153              self::$handler->set_requires_write_lock($requireslock);
 154              self::prepare_cookies();
 155              $isnewsession = empty($_COOKIE[session_name()]);
 156  
 157              if (!self::$handler->start()) {
 158                  // Could not successfully start/recover session.
 159                  throw new \core\session\exception(get_string('servererror'));
 160              }
 161  
 162              // Grab the time when session lock starts.
 163              $PERF->sessionlock['gained'] = microtime(true);
 164              $PERF->sessionlock['wait'] = $PERF->sessionlock['gained'] - $PERF->sessionlock['start'];
 165              self::initialise_user_session($isnewsession);
 166              self::$sessionactive = true; // Set here, so the session can be cleared if the security check fails.
 167              self::check_security();
 168  
 169              if (!$requireslock || !self::$requireslockdebug) {
 170                  self::$priorsession = (array) $_SESSION['SESSION'];
 171              }
 172  
 173              if (!empty($CFG->enable_read_only_sessions) && isset($_SESSION['SESSION']->cachestore_session)) {
 174                  $caches = join(', ', array_keys($_SESSION['SESSION']->cachestore_session));
 175                  $caches = str_replace('default_session-', '', $caches);
 176                  throw new \moodle_exception("The session caches can not be in the session store when "
 177                      . "enable_read_only_sessions is enabled. Please map all session mode caches to be outside of the "
 178                      . "default session store before enabling this features. Found these definitions in the session: $caches");
 179              }
 180  
 181              // Link global $USER and $SESSION,
 182              // this is tricky because PHP does not allow references to references
 183              // and global keyword uses internally once reference to the $GLOBALS array.
 184              // The solution is to use the $GLOBALS['USER'] and $GLOBALS['$SESSION']
 185              // as the main storage of data and put references to $_SESSION.
 186              $GLOBALS['USER'] = $_SESSION['USER'];
 187              $_SESSION['USER'] =& $GLOBALS['USER'];
 188              $GLOBALS['SESSION'] = $_SESSION['SESSION'];
 189              $_SESSION['SESSION'] =& $GLOBALS['SESSION'];
 190  
 191          } catch (\Exception $ex) {
 192              self::init_empty_session();
 193              self::$sessionactive = false;
 194              throw $ex;
 195          }
 196      }
 197  
 198      /**
 199       * Returns current page performance info.
 200       *
 201       * @return array perf info
 202       */
 203      public static function get_performance_info() {
 204          global $CFG, $PERF;
 205  
 206          if (!session_id()) {
 207              return array();
 208          }
 209  
 210          self::load_handler();
 211          $size = display_size(strlen(session_encode()));
 212          $handler = get_class(self::$handler);
 213  
 214          $info = array();
 215          $info['size'] = $size;
 216          $info['html'] = html_writer::div("Session ($handler): $size", "sessionsize");
 217          $info['txt'] = "Session ($handler): $size ";
 218  
 219          if (!empty($CFG->debugsessionlock)) {
 220              $sessionlock = self::get_session_lock_info();
 221              if (!empty($sessionlock['held'])) {
 222                  // The page displays the footer and the session has been closed.
 223                  $sessionlocktext = "Session lock held: ".number_format($sessionlock['held'], 3)." secs";
 224              } else {
 225                  // The session hasn't yet been closed and so we assume now with microtime.
 226                  $sessionlockheld = microtime(true) - $PERF->sessionlock['gained'];
 227                  $sessionlocktext = "Session lock open: ".number_format($sessionlockheld, 3)." secs";
 228              }
 229              $info['txt'] .= $sessionlocktext;
 230              $info['html'] .= html_writer::div($sessionlocktext, "sessionlockstart");
 231              $sessionlockwaittext = "Session lock wait: ".number_format($sessionlock['wait'], 3)." secs";
 232              $info['txt'] .= $sessionlockwaittext;
 233              $info['html'] .= html_writer::div($sessionlockwaittext, "sessionlockwait");
 234          }
 235  
 236          return $info;
 237      }
 238  
 239      /**
 240       * Get fully qualified name of session handler class.
 241       *
 242       * @return string The name of the handler class
 243       */
 244      public static function get_handler_class() {
 245          global $CFG, $DB;
 246  
 247          if (PHPUNIT_TEST) {
 248              return '\core\session\file';
 249          } else if (!empty($CFG->session_handler_class)) {
 250              return $CFG->session_handler_class;
 251          } else if (!empty($CFG->dbsessions) and $DB->session_lock_supported()) {
 252              return '\core\session\database';
 253          }
 254  
 255          return '\core\session\file';
 256      }
 257  
 258      /**
 259       * Create handler instance.
 260       */
 261      protected static function load_handler() {
 262          if (self::$handler) {
 263              return;
 264          }
 265  
 266          // Find out which handler to use.
 267          $class = self::get_handler_class();
 268          self::$handler = new $class();
 269      }
 270  
 271      /**
 272       * Empty current session, fill it with not-logged-in user info.
 273       *
 274       * This is intended for installation scripts, unit tests and other
 275       * special areas. Do NOT use for logout and session termination
 276       * in normal requests!
 277       */
 278      public static function init_empty_session() {
 279          global $CFG;
 280  
 281          if (isset($GLOBALS['SESSION']->notifications)) {
 282              // Backup notifications. These should be preserved across session changes until the user fetches and clears them.
 283              $notifications = $GLOBALS['SESSION']->notifications;
 284          }
 285          $GLOBALS['SESSION'] = new \stdClass();
 286  
 287          $GLOBALS['USER'] = new \stdClass();
 288          $GLOBALS['USER']->id = 0;
 289  
 290          if (!empty($notifications)) {
 291              // Restore notifications.
 292              $GLOBALS['SESSION']->notifications = $notifications;
 293          }
 294          if (isset($CFG->mnet_localhost_id)) {
 295              $GLOBALS['USER']->mnethostid = $CFG->mnet_localhost_id;
 296          } else {
 297              // Not installed yet, the future host id will be most probably 1.
 298              $GLOBALS['USER']->mnethostid = 1;
 299          }
 300  
 301          // Link global $USER and $SESSION.
 302          $_SESSION = array();
 303          $_SESSION['USER'] =& $GLOBALS['USER'];
 304          $_SESSION['SESSION'] =& $GLOBALS['SESSION'];
 305      }
 306  
 307      /**
 308       * Make sure all cookie and session related stuff is configured properly before session start.
 309       */
 310      protected static function prepare_cookies() {
 311          global $CFG;
 312  
 313          $cookiesecure = is_moodle_cookie_secure();
 314  
 315          if (!isset($CFG->cookiehttponly)) {
 316              $CFG->cookiehttponly = 0;
 317          }
 318  
 319          // Set sessioncookie variable if it isn't already.
 320          if (!isset($CFG->sessioncookie)) {
 321              $CFG->sessioncookie = '';
 322          }
 323          $sessionname = 'MoodleSession'.$CFG->sessioncookie;
 324  
 325          // Make sure cookie domain makes sense for this wwwroot.
 326          if (!isset($CFG->sessioncookiedomain)) {
 327              $CFG->sessioncookiedomain = '';
 328          } else if ($CFG->sessioncookiedomain !== '') {
 329              $host = parse_url($CFG->wwwroot, PHP_URL_HOST);
 330              if ($CFG->sessioncookiedomain !== $host) {
 331                  if (substr($CFG->sessioncookiedomain, 0, 1) === '.') {
 332                      if (!preg_match('|^.*'.preg_quote($CFG->sessioncookiedomain, '|').'$|', $host)) {
 333                          // Invalid domain - it must be end part of host.
 334                          $CFG->sessioncookiedomain = '';
 335                      }
 336                  } else {
 337                      if (!preg_match('|^.*\.'.preg_quote($CFG->sessioncookiedomain, '|').'$|', $host)) {
 338                          // Invalid domain - it must be end part of host.
 339                          $CFG->sessioncookiedomain = '';
 340                      }
 341                  }
 342              }
 343          }
 344  
 345          // Make sure the cookiepath is valid for this wwwroot or autodetect if not specified.
 346          if (!isset($CFG->sessioncookiepath)) {
 347              $CFG->sessioncookiepath = '';
 348          }
 349          if ($CFG->sessioncookiepath !== '/') {
 350              $path = parse_url($CFG->wwwroot, PHP_URL_PATH).'/';
 351              if ($CFG->sessioncookiepath === '') {
 352                  $CFG->sessioncookiepath = $path;
 353              } else {
 354                  if (strpos($path, $CFG->sessioncookiepath) !== 0 or substr($CFG->sessioncookiepath, -1) !== '/') {
 355                      $CFG->sessioncookiepath = $path;
 356                  }
 357              }
 358          }
 359  
 360          // Discard session ID from POST, GET and globals to tighten security,
 361          // this is session fixation prevention.
 362          unset($GLOBALS[$sessionname]);
 363          unset($_GET[$sessionname]);
 364          unset($_POST[$sessionname]);
 365          unset($_REQUEST[$sessionname]);
 366  
 367          // Compatibility hack for non-browser access to our web interface.
 368          if (!empty($_COOKIE[$sessionname]) && $_COOKIE[$sessionname] == "deleted") {
 369              unset($_COOKIE[$sessionname]);
 370          }
 371  
 372          // Set configuration.
 373          session_name($sessionname);
 374  
 375          $sessionoptions = [
 376              'lifetime' => 0,
 377              'path' => $CFG->sessioncookiepath,
 378              'domain' => $CFG->sessioncookiedomain,
 379              'secure' => $cookiesecure,
 380              'httponly' => $CFG->cookiehttponly,
 381          ];
 382  
 383          if (self::should_use_samesite_none()) {
 384              // If $samesite is empty, we don't want there to be any SameSite attribute.
 385              $sessionoptions['samesite'] = 'None';
 386          }
 387  
 388          session_set_cookie_params($sessionoptions);
 389  
 390          ini_set('session.use_trans_sid', '0');
 391          ini_set('session.use_only_cookies', '1');
 392          ini_set('session.use_strict_mode', '0');      // We have custom protection in session init.
 393          ini_set('session.serialize_handler', 'php');  // We can move to 'php_serialize' after we require PHP 5.5.4 form Moodle.
 394  
 395          // Moodle does normal session timeouts, this is for leftovers only.
 396          ini_set('session.gc_probability', 1);
 397          ini_set('session.gc_divisor', 1000);
 398          ini_set('session.gc_maxlifetime', 60*60*24*4);
 399      }
 400  
 401      /**
 402       * Initialise $_SESSION, handles google access
 403       * and sets up not-logged-in user properly.
 404       *
 405       * WARNING: $USER and $SESSION are set up later, do not use them yet!
 406       *
 407       * @param bool $newsid is this a new session in first http request?
 408       */
 409      protected static function initialise_user_session($newsid) {
 410          global $CFG, $DB;
 411  
 412          $sid = session_id();
 413          if (!$sid) {
 414              // No session, very weird.
 415              error_log('Missing session ID, session not started!');
 416              self::init_empty_session();
 417              return;
 418          }
 419  
 420          if (!$record = $DB->get_record('sessions', array('sid'=>$sid), 'id, sid, state, userid, lastip, timecreated, timemodified')) {
 421              if (!$newsid) {
 422                  if (!empty($_SESSION['USER']->id)) {
 423                      // This should not happen, just log it, we MUST not produce any output here!
 424                      error_log("Cannot find session record $sid for user ".$_SESSION['USER']->id.", creating new session.");
 425                  }
 426                  // Prevent session fixation attacks.
 427                  session_regenerate_id(true);
 428              }
 429              $_SESSION = array();
 430          }
 431          unset($sid);
 432  
 433          if (isset($_SESSION['USER']->id)) {
 434              if (!empty($_SESSION['USER']->realuser)) {
 435                  $userid = $_SESSION['USER']->realuser;
 436              } else {
 437                  $userid = $_SESSION['USER']->id;
 438              }
 439  
 440              // Verify timeout first.
 441              $maxlifetime = $CFG->sessiontimeout;
 442              $timeout = false;
 443              if (isguestuser($userid) or empty($userid)) {
 444                  // Ignore guest and not-logged in timeouts, there is very little risk here.
 445                  $timeout = false;
 446  
 447              } else if ($record->timemodified < time() - $maxlifetime) {
 448                  $timeout = true;
 449                  $authsequence = get_enabled_auth_plugins(); // Auths, in sequence.
 450                  foreach ($authsequence as $authname) {
 451                      $authplugin = get_auth_plugin($authname);
 452                      if ($authplugin->ignore_timeout_hook($_SESSION['USER'], $record->sid, $record->timecreated, $record->timemodified)) {
 453                          $timeout = false;
 454                          break;
 455                      }
 456                  }
 457              }
 458  
 459              if ($timeout) {
 460                  if (defined('NO_SESSION_UPDATE') && NO_SESSION_UPDATE) {
 461                      return;
 462                  }
 463                  session_regenerate_id(true);
 464                  $_SESSION = array();
 465                  $DB->delete_records('sessions', array('id'=>$record->id));
 466  
 467              } else {
 468                  // Update session tracking record.
 469  
 470                  $update = new \stdClass();
 471                  $updated = false;
 472  
 473                  if ($record->userid != $userid) {
 474                      $update->userid = $record->userid = $userid;
 475                      $updated = true;
 476                  }
 477  
 478                  $ip = getremoteaddr();
 479                  if ($record->lastip != $ip) {
 480                      $update->lastip = $record->lastip = $ip;
 481                      $updated = true;
 482                  }
 483  
 484                  $updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency;
 485  
 486                  if ($record->timemodified == $record->timecreated) {
 487                      // Always do first update of existing record.
 488                      $update->timemodified = $record->timemodified = time();
 489                      $updated = true;
 490  
 491                  } else if ($record->timemodified < time() - $updatefreq) {
 492                      // Update the session modified flag only once every 20 seconds.
 493                      $update->timemodified = $record->timemodified = time();
 494                      $updated = true;
 495                  }
 496  
 497                  if ($updated && (!defined('NO_SESSION_UPDATE') || !NO_SESSION_UPDATE)) {
 498                      $update->id = $record->id;
 499                      $DB->update_record('sessions', $update);
 500                  }
 501  
 502                  return;
 503              }
 504          } else {
 505              if ($record) {
 506                  // This happens when people switch session handlers...
 507                  session_regenerate_id(true);
 508                  $_SESSION = array();
 509                  $DB->delete_records('sessions', array('id'=>$record->id));
 510              }
 511          }
 512          unset($record);
 513  
 514          $timedout = false;
 515          if (!isset($_SESSION['SESSION'])) {
 516              $_SESSION['SESSION'] = new \stdClass();
 517              if (!$newsid) {
 518                  $timedout = true;
 519              }
 520          }
 521  
 522          $user = null;
 523  
 524          if (!empty($CFG->opentowebcrawlers)) {
 525              if (\core_useragent::is_web_crawler()) {
 526                  $user = guest_user();
 527              }
 528              $referer = get_local_referer(false);
 529              if (!empty($CFG->guestloginbutton) and !$user and !empty($referer)) {
 530                  // Automatically log in users coming from search engine results.
 531                  if (strpos($referer, 'google') !== false ) {
 532                      $user = guest_user();
 533                  } else if (strpos($referer, 'altavista') !== false ) {
 534                      $user = guest_user();
 535                  }
 536              }
 537          }
 538  
 539          // Setup $USER and insert the session tracking record.
 540          if ($user) {
 541              self::set_user($user);
 542              self::add_session_record($user->id);
 543          } else {
 544              self::init_empty_session();
 545              self::add_session_record(0);
 546          }
 547  
 548          if ($timedout) {
 549              $_SESSION['SESSION']->has_timed_out = true;
 550          }
 551      }
 552  
 553      /**
 554       * Insert new empty session record.
 555       * @param int $userid
 556       * @return \stdClass the new record
 557       */
 558      protected static function add_session_record($userid) {
 559          global $DB;
 560          $record = new \stdClass();
 561          $record->state       = 0;
 562          $record->sid         = session_id();
 563          $record->sessdata    = null;
 564          $record->userid      = $userid;
 565          $record->timecreated = $record->timemodified = time();
 566          $record->firstip     = $record->lastip = getremoteaddr();
 567  
 568          $record->id = $DB->insert_record('sessions', $record);
 569  
 570          return $record;
 571      }
 572  
 573      /**
 574       * Do various session security checks.
 575       *
 576       * WARNING: $USER and $SESSION are set up later, do not use them yet!
 577       * @throws \core\session\exception
 578       */
 579      protected static function check_security() {
 580          global $CFG;
 581  
 582          if (!empty($_SESSION['USER']->id) and !empty($CFG->tracksessionip)) {
 583              // Make sure current IP matches the one for this session.
 584              $remoteaddr = getremoteaddr();
 585  
 586              if (empty($_SESSION['USER']->sessionip)) {
 587                  $_SESSION['USER']->sessionip = $remoteaddr;
 588              }
 589  
 590              if ($_SESSION['USER']->sessionip != $remoteaddr) {
 591                  // This is a security feature - terminate the session in case of any doubt.
 592                  self::terminate_current();
 593                  throw new exception('sessionipnomatch2', 'error');
 594              }
 595          }
 596      }
 597  
 598      /**
 599       * Login user, to be called from complete_user_login() only.
 600       * @param \stdClass $user
 601       */
 602      public static function login_user(\stdClass $user) {
 603          global $DB;
 604  
 605          // Regenerate session id and delete old session,
 606          // this helps prevent session fixation attacks from the same domain.
 607  
 608          $sid = session_id();
 609          session_regenerate_id(true);
 610          $DB->delete_records('sessions', array('sid'=>$sid));
 611          self::add_session_record($user->id);
 612  
 613          // Let enrol plugins deal with new enrolments if necessary.
 614          enrol_check_plugins($user);
 615  
 616          // Setup $USER object.
 617          self::set_user($user);
 618      }
 619  
 620      /**
 621       * Returns a valid setting for the SameSite cookie attribute.
 622       *
 623       * @return string The desired setting for the SameSite attribute on the cookie. Empty string indicates the SameSite attribute
 624       * should not be set at all.
 625       */
 626      private static function should_use_samesite_none(): bool {
 627          // We only want None or no attribute at this point. When we have cookie handling compatible with Lax,
 628          // we can look at checking a setting.
 629  
 630          // Browser support for none is not consistent yet. There are known issues with Safari, and IE11.
 631          // Things are stablising, however as they're not stable yet we will deal specifically with the version of chrome
 632          // that introduces a default of lax, setting it to none for the current version of chrome (2 releases before the change).
 633          // We also check you are using secure cookies and HTTPS because if you are not running over HTTPS
 634          // then setting SameSite=None will cause your session cookie to be rejected.
 635          if (\core_useragent::is_chrome() && \core_useragent::check_chrome_version('78') && is_moodle_cookie_secure()) {
 636              return true;
 637          }
 638          return false;
 639      }
 640  
 641      /**
 642       * Terminate current user session.
 643       * @return void
 644       */
 645      public static function terminate_current() {
 646          global $DB;
 647  
 648          if (!self::$sessionactive) {
 649              self::init_empty_session();
 650              self::$sessionactive = false;
 651              return;
 652          }
 653  
 654          try {
 655              $DB->delete_records('external_tokens', array('sid'=>session_id(), 'tokentype'=>EXTERNAL_TOKEN_EMBEDDED));
 656          } catch (\Exception $ignored) {
 657              // Probably install/upgrade - ignore this problem.
 658          }
 659  
 660          // Initialize variable to pass-by-reference to headers_sent(&$file, &$line).
 661          $file = null;
 662          $line = null;
 663          if (headers_sent($file, $line)) {
 664              error_log('Cannot terminate session properly - headers were already sent in file: '.$file.' on line '.$line);
 665          }
 666  
 667          // Write new empty session and make sure the old one is deleted.
 668          $sid = session_id();
 669          session_regenerate_id(true);
 670          $DB->delete_records('sessions', array('sid'=>$sid));
 671          self::init_empty_session();
 672          self::add_session_record($_SESSION['USER']->id); // Do not use $USER here because it may not be set up yet.
 673          self::write_close();
 674      }
 675  
 676      /**
 677       * No more changes in session expected.
 678       * Unblocks the sessions, other scripts may start executing in parallel.
 679       */
 680      public static function write_close() {
 681          global $PERF, $ME, $CFG;
 682  
 683          if (self::$sessionactive) {
 684              // Grab the time when session lock is released.
 685              $PERF->sessionlock['released'] = microtime(true);
 686              if (!empty($PERF->sessionlock['gained'])) {
 687                  $PERF->sessionlock['held'] = $PERF->sessionlock['released'] - $PERF->sessionlock['gained'];
 688              }
 689              $PERF->sessionlock['url'] = me();
 690              self::update_recent_session_locks($PERF->sessionlock);
 691              self::sessionlock_debugging();
 692  
 693              $requireslock = self::$handler->requires_write_lock();
 694              if (!$requireslock || !self::$requireslockdebug) {
 695                  // Compare the array of the earlier session data with the array now, if
 696                  // there is a difference then a lock is required.
 697                  $arraydiff = self::array_session_diff(
 698                      self::$priorsession,
 699                      (array) $_SESSION['SESSION']
 700                  );
 701  
 702                  if ($arraydiff) {
 703                      $error = "Script $ME defined READ_ONLY_SESSION but the following SESSION attributes were changed:";
 704                      foreach ($arraydiff as $key => $value) {
 705                          $error .= ' $SESSION->' . $key;
 706                      }
 707                      // This will emit an error if debugging is on, even if $CFG->enable_read_only_sessions
 708                      // is not true as we need to surface this class of errors.
 709                      error_log($error); // phpcs:ignore
 710                  }
 711              }
 712          }
 713  
 714          // More control over whether session data
 715          // is persisted or not.
 716          if (self::$sessionactive && session_id()) {
 717              // Write session and release lock only if
 718              // indication session start was clean.
 719              self::$handler->write_close();
 720          } else {
 721              // Otherwise, if possible lock exists want
 722              // to clear it, but do not write session.
 723              // If the $handler has not been set then
 724              // there is no session to abort.
 725              if (isset(self::$handler)) {
 726                  @self::$handler->abort();
 727              }
 728          }
 729  
 730          self::$sessionactive = false;
 731      }
 732  
 733      /**
 734       * Does the PHP session with given id exist?
 735       *
 736       * The session must exist both in session table and actual
 737       * session backend and the session must not be timed out.
 738       *
 739       * Timeout evaluation is simplified, the auth hooks are not executed.
 740       *
 741       * @param string $sid
 742       * @return bool
 743       */
 744      public static function session_exists($sid) {
 745          global $DB, $CFG;
 746  
 747          if (empty($CFG->version)) {
 748              // Not installed yet, do not try to access database.
 749              return false;
 750          }
 751  
 752          // Note: add sessions->state checking here if it gets implemented.
 753          if (!$record = $DB->get_record('sessions', array('sid' => $sid), 'id, userid, timemodified')) {
 754              return false;
 755          }
 756  
 757          if (empty($record->userid) or isguestuser($record->userid)) {
 758              // Ignore guest and not-logged-in timeouts, there is very little risk here.
 759          } else if ($record->timemodified < time() - $CFG->sessiontimeout) {
 760              return false;
 761          }
 762  
 763          // There is no need the existence of handler storage in public API.
 764          self::load_handler();
 765          return self::$handler->session_exists($sid);
 766      }
 767  
 768      /**
 769       * Return the number of seconds remaining in the current session.
 770       * @param string $sid
 771       */
 772      public static function time_remaining($sid) {
 773          global $DB, $CFG;
 774  
 775          if (empty($CFG->version)) {
 776              // Not installed yet, do not try to access database.
 777              return ['userid' => 0, 'timeremaining' => $CFG->sessiontimeout];
 778          }
 779  
 780          // Note: add sessions->state checking here if it gets implemented.
 781          if (!$record = $DB->get_record('sessions', array('sid' => $sid), 'id, userid, timemodified')) {
 782              return ['userid' => 0, 'timeremaining' => $CFG->sessiontimeout];
 783          }
 784  
 785          if (empty($record->userid) or isguestuser($record->userid)) {
 786              // Ignore guest and not-logged-in timeouts, there is very little risk here.
 787              return ['userid' => 0, 'timeremaining' => $CFG->sessiontimeout];
 788          } else {
 789              return ['userid' => $record->userid, 'timeremaining' => $CFG->sessiontimeout - (time() - $record->timemodified)];
 790          }
 791      }
 792  
 793      /**
 794       * Fake last access for given session, this prevents session timeout.
 795       * @param string $sid
 796       */
 797      public static function touch_session($sid) {
 798          global $DB;
 799  
 800          // Timeouts depend on core sessions table only, no need to update anything in external stores.
 801  
 802          $sql = "UPDATE {sessions} SET timemodified = :now WHERE sid = :sid";
 803          $DB->execute($sql, array('now'=>time(), 'sid'=>$sid));
 804      }
 805  
 806      /**
 807       * Terminate all sessions unconditionally.
 808       */
 809      public static function kill_all_sessions() {
 810          global $DB;
 811  
 812          self::terminate_current();
 813  
 814          self::load_handler();
 815          self::$handler->kill_all_sessions();
 816  
 817          try {
 818              $DB->delete_records('sessions');
 819          } catch (\dml_exception $ignored) {
 820              // Do not show any warnings - might be during upgrade/installation.
 821          }
 822      }
 823  
 824      /**
 825       * Terminate give session unconditionally.
 826       * @param string $sid
 827       */
 828      public static function kill_session($sid) {
 829          global $DB;
 830  
 831          self::load_handler();
 832  
 833          if ($sid === session_id()) {
 834              self::write_close();
 835          }
 836  
 837          self::$handler->kill_session($sid);
 838  
 839          $DB->delete_records('sessions', array('sid'=>$sid));
 840      }
 841  
 842      /**
 843       * Terminate all sessions of given user unconditionally.
 844       * @param int $userid
 845       * @param string $keepsid keep this sid if present
 846       */
 847      public static function kill_user_sessions($userid, $keepsid = null) {
 848          global $DB;
 849  
 850          $sessions = $DB->get_records('sessions', array('userid'=>$userid), 'id DESC', 'id, sid');
 851          foreach ($sessions as $session) {
 852              if ($keepsid and $keepsid === $session->sid) {
 853                  continue;
 854              }
 855              self::kill_session($session->sid);
 856          }
 857      }
 858  
 859      /**
 860       * Terminate other sessions of current user depending
 861       * on $CFG->limitconcurrentlogins restriction.
 862       *
 863       * This is expected to be called right after complete_user_login().
 864       *
 865       * NOTE:
 866       *  * Do not use from SSO auth plugins, this would not work.
 867       *  * Do not use from web services because they do not have sessions.
 868       *
 869       * @param int $userid
 870       * @param string $sid session id to be always keep, usually the current one
 871       * @return void
 872       */
 873      public static function apply_concurrent_login_limit($userid, $sid = null) {
 874          global $CFG, $DB;
 875  
 876          // NOTE: the $sid parameter is here mainly to allow testing,
 877          //       in most cases it should be current session id.
 878  
 879          if (isguestuser($userid) or empty($userid)) {
 880              // This applies to real users only!
 881              return;
 882          }
 883  
 884          if (empty($CFG->limitconcurrentlogins) or $CFG->limitconcurrentlogins < 0) {
 885              return;
 886          }
 887  
 888          $count = $DB->count_records('sessions', array('userid' => $userid));
 889  
 890          if ($count <= $CFG->limitconcurrentlogins) {
 891              return;
 892          }
 893  
 894          $i = 0;
 895          $select = "userid = :userid";
 896          $params = array('userid' => $userid);
 897          if ($sid) {
 898              if ($DB->record_exists('sessions', array('sid' => $sid, 'userid' => $userid))) {
 899                  $select .= " AND sid <> :sid";
 900                  $params['sid'] = $sid;
 901                  $i = 1;
 902              }
 903          }
 904  
 905          $sessions = $DB->get_records_select('sessions', $select, $params, 'timecreated DESC', 'id, sid');
 906          foreach ($sessions as $session) {
 907              $i++;
 908              if ($i <= $CFG->limitconcurrentlogins) {
 909                  continue;
 910              }
 911              self::kill_session($session->sid);
 912          }
 913      }
 914  
 915      /**
 916       * Set current user.
 917       *
 918       * @param \stdClass $user record
 919       */
 920      public static function set_user(\stdClass $user) {
 921          global $ADMIN;
 922          $GLOBALS['USER'] = $user;
 923          unset($GLOBALS['USER']->description); // Conserve memory.
 924          unset($GLOBALS['USER']->password);    // Improve security.
 925          if (isset($GLOBALS['USER']->lang)) {
 926              // Make sure it is a valid lang pack name.
 927              $GLOBALS['USER']->lang = clean_param($GLOBALS['USER']->lang, PARAM_LANG);
 928          }
 929  
 930          // Relink session with global $USER just in case it got unlinked somehow.
 931          $_SESSION['USER'] =& $GLOBALS['USER'];
 932  
 933          // Nullify the $ADMIN tree global. If we're changing users, then this is now stale and must be generated again if needed.
 934          $ADMIN = null;
 935  
 936          // Init session key.
 937          sesskey();
 938      }
 939  
 940      /**
 941       * Periodic timed-out session cleanup.
 942       */
 943      public static function gc() {
 944          global $CFG, $DB;
 945  
 946          // This may take a long time...
 947          \core_php_time_limit::raise();
 948  
 949          $maxlifetime = $CFG->sessiontimeout;
 950  
 951          try {
 952              // Kill all sessions of deleted and suspended users without any hesitation.
 953              $rs = $DB->get_recordset_select('sessions', "userid IN (SELECT id FROM {user} WHERE deleted <> 0 OR suspended <> 0)", array(), 'id DESC', 'id, sid');
 954              foreach ($rs as $session) {
 955                  self::kill_session($session->sid);
 956              }
 957              $rs->close();
 958  
 959              // Kill sessions of users with disabled plugins.
 960              $authsequence = get_enabled_auth_plugins();
 961              $authsequence = array_flip($authsequence);
 962              unset($authsequence['nologin']); // No login means user cannot login.
 963              $authsequence = array_flip($authsequence);
 964  
 965              list($notplugins, $params) = $DB->get_in_or_equal($authsequence, SQL_PARAMS_QM, '', false);
 966              $rs = $DB->get_recordset_select('sessions', "userid IN (SELECT id FROM {user} WHERE auth $notplugins)", $params, 'id DESC', 'id, sid');
 967              foreach ($rs as $session) {
 968                  self::kill_session($session->sid);
 969              }
 970              $rs->close();
 971  
 972              // Now get a list of time-out candidates - real users only.
 973              $sql = "SELECT u.*, s.sid, s.timecreated AS s_timecreated, s.timemodified AS s_timemodified
 974                        FROM {user} u
 975                        JOIN {sessions} s ON s.userid = u.id
 976                       WHERE s.timemodified < :purgebefore AND u.id <> :guestid";
 977              $params = array('purgebefore' => (time() - $maxlifetime), 'guestid'=>$CFG->siteguest);
 978  
 979              $authplugins = array();
 980              foreach ($authsequence as $authname) {
 981                  $authplugins[$authname] = get_auth_plugin($authname);
 982              }
 983              $rs = $DB->get_recordset_sql($sql, $params);
 984              foreach ($rs as $user) {
 985                  foreach ($authplugins as $authplugin) {
 986                      /** @var \auth_plugin_base $authplugin*/
 987                      if ($authplugin->ignore_timeout_hook($user, $user->sid, $user->s_timecreated, $user->s_timemodified)) {
 988                          continue 2;
 989                      }
 990                  }
 991                  self::kill_session($user->sid);
 992              }
 993              $rs->close();
 994  
 995              // Delete expired sessions for guest user account, give them larger timeout, there is no security risk here.
 996              $params = array('purgebefore' => (time() - ($maxlifetime * 5)), 'guestid'=>$CFG->siteguest);
 997              $rs = $DB->get_recordset_select('sessions', 'userid = :guestid AND timemodified < :purgebefore', $params, 'id DESC', 'id, sid');
 998              foreach ($rs as $session) {
 999                  self::kill_session($session->sid);
1000              }
1001              $rs->close();
1002  
1003              // Delete expired sessions for userid = 0 (not logged in), better kill them asap to release memory.
1004              $params = array('purgebefore' => (time() - $maxlifetime));
1005              $rs = $DB->get_recordset_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params, 'id DESC', 'id, sid');
1006              foreach ($rs as $session) {
1007                  self::kill_session($session->sid);
1008              }
1009              $rs->close();
1010  
1011              // Cleanup letfovers from the first browser access because it may set multiple cookies and then use only one.
1012              $params = array('purgebefore' => (time() - 60*3));
1013              $rs = $DB->get_recordset_select('sessions', 'userid = 0 AND timemodified = timecreated AND timemodified < :purgebefore', $params, 'id ASC', 'id, sid');
1014              foreach ($rs as $session) {
1015                  self::kill_session($session->sid);
1016              }
1017              $rs->close();
1018  
1019          } catch (\Exception $ex) {
1020              debugging('Error gc-ing sessions: '.$ex->getMessage(), DEBUG_NORMAL, $ex->getTrace());
1021          }
1022      }
1023  
1024      /**
1025       * Is current $USER logged-in-as somebody else?
1026       * @return bool
1027       */
1028      public static function is_loggedinas() {
1029          return !empty($GLOBALS['USER']->realuser);
1030      }
1031  
1032      /**
1033       * Returns the $USER object ignoring current login-as session
1034       * @return \stdClass user object
1035       */
1036      public static function get_realuser() {
1037          if (self::is_loggedinas()) {
1038              return $_SESSION['REALUSER'];
1039          } else {
1040              return $GLOBALS['USER'];
1041          }
1042      }
1043  
1044      /**
1045       * Login as another user - no security checks here.
1046       * @param int $userid
1047       * @param \context $context
1048       * @param bool $generateevent Set to false to prevent the loginas event to be generated
1049       * @return void
1050       */
1051      public static function loginas($userid, \context $context, $generateevent = true) {
1052          global $USER;
1053  
1054          if (self::is_loggedinas()) {
1055              return;
1056          }
1057  
1058          // Switch to fresh new $_SESSION.
1059          $_SESSION = array();
1060          $_SESSION['REALSESSION'] = clone($GLOBALS['SESSION']);
1061          $GLOBALS['SESSION'] = new \stdClass();
1062          $_SESSION['SESSION'] =& $GLOBALS['SESSION'];
1063  
1064          // Create the new $USER object with all details and reload needed capabilities.
1065          $_SESSION['REALUSER'] = clone($GLOBALS['USER']);
1066          $user = get_complete_user_data('id', $userid);
1067          $user->realuser       = $_SESSION['REALUSER']->id;
1068          $user->loginascontext = $context;
1069  
1070          // Let enrol plugins deal with new enrolments if necessary.
1071          enrol_check_plugins($user);
1072  
1073          if ($generateevent) {
1074              // Create event before $USER is updated.
1075              $event = \core\event\user_loggedinas::create(
1076                  array(
1077                      'objectid' => $USER->id,
1078                      'context' => $context,
1079                      'relateduserid' => $userid,
1080                      'other' => array(
1081                          'originalusername' => fullname($USER, true),
1082                          'loggedinasusername' => fullname($user, true)
1083                      )
1084                  )
1085              );
1086          }
1087  
1088          // Set up global $USER.
1089          \core\session\manager::set_user($user);
1090  
1091          if ($generateevent) {
1092              $event->trigger();
1093          }
1094  
1095          // Queue migrating the messaging data, if we need to.
1096          if (!get_user_preferences('core_message_migrate_data', false, $userid)) {
1097              // Check if there are any legacy messages to migrate.
1098              if (\core_message\helper::legacy_messages_exist($userid)) {
1099                  \core_message\task\migrate_message_data::queue_task($userid);
1100              } else {
1101                  set_user_preference('core_message_migrate_data', true, $userid);
1102              }
1103          }
1104      }
1105  
1106      /**
1107       * Add a JS session keepalive to the page.
1108       *
1109       * A JS session keepalive script will be called to update the session modification time every $frequency seconds.
1110       *
1111       * Upon failure, the specified error message will be shown to the user.
1112       *
1113       * @param string $identifier The string identifier for the message to show on failure.
1114       * @param string $component The string component for the message to show on failure.
1115       * @param int $frequency The update frequency in seconds.
1116       * @param int $timeout The timeout of each request in seconds.
1117       * @throws coding_exception IF the frequency is longer than the session lifetime.
1118       */
1119      public static function keepalive($identifier = 'sessionerroruser', $component = 'error', $frequency = null, $timeout = 0) {
1120          global $CFG, $PAGE;
1121  
1122          if ($frequency) {
1123              if ($frequency > $CFG->sessiontimeout) {
1124                  // Sanity check the frequency.
1125                  throw new \coding_exception('Keepalive frequency is longer than the session lifespan.');
1126              }
1127          } else {
1128              // A frequency of sessiontimeout / 10 matches the timeouts in core/network amd module.
1129              $frequency = $CFG->sessiontimeout / 10;
1130          }
1131  
1132          $PAGE->requires->js_call_amd('core/network', 'keepalive', array(
1133                  $frequency,
1134                  $timeout,
1135                  get_string($identifier, $component)
1136              ));
1137      }
1138  
1139      /**
1140       * Generate a new login token and store it in the session.
1141       *
1142       * @return array The current login state.
1143       */
1144      private static function create_login_token() {
1145          global $SESSION;
1146  
1147          $state = [
1148              'token' => random_string(32),
1149              'created' => time() // Server time - not user time.
1150          ];
1151  
1152          if (!isset($SESSION->logintoken)) {
1153              $SESSION->logintoken = [];
1154          }
1155  
1156          // Overwrite any previous values.
1157          $SESSION->logintoken[self::$logintokenkey] = $state;
1158  
1159          return $state;
1160      }
1161  
1162      /**
1163       * Get the current login token or generate a new one.
1164       *
1165       * All login forms generated from Moodle must include a login token
1166       * named "logintoken" with the value being the result of this function.
1167       * Logins will be rejected if they do not include this token as well as
1168       * the username and password fields.
1169       *
1170       * @return string The current login token.
1171       */
1172      public static function get_login_token() {
1173          global $CFG, $SESSION;
1174  
1175          $state = false;
1176  
1177          if (!isset($SESSION->logintoken)) {
1178              $SESSION->logintoken = [];
1179          }
1180  
1181          if (array_key_exists(self::$logintokenkey, $SESSION->logintoken)) {
1182              $state = $SESSION->logintoken[self::$logintokenkey];
1183          }
1184          if (empty($state)) {
1185              $state = self::create_login_token();
1186          }
1187  
1188          // Check token lifespan.
1189          if ($state['created'] < (time() - $CFG->sessiontimeout)) {
1190              $state = self::create_login_token();
1191          }
1192  
1193          // Return the current session login token.
1194          if (array_key_exists('token', $state)) {
1195              return $state['token'];
1196          } else {
1197              return false;
1198          }
1199      }
1200  
1201      /**
1202       * Check the submitted value against the stored login token.
1203       *
1204       * @param mixed $token The value submitted in the login form that we are validating.
1205       *                     If false is passed for the token, this function will always return true.
1206       * @return boolean If the submitted token is valid.
1207       */
1208      public static function validate_login_token($token = false) {
1209          global $CFG;
1210  
1211          if (!empty($CFG->alternateloginurl) || !empty($CFG->disablelogintoken)) {
1212              // An external login page cannot generate the login token we need to protect CSRF on
1213              // login requests.
1214              // Other custom login workflows may skip this check by setting disablelogintoken in config.
1215              return true;
1216          }
1217          if ($token === false) {
1218              // authenticate_user_login is a core function was extended to validate tokens.
1219              // For existing uses other than the login form it does not
1220              // validate that a token was generated.
1221              // Some uses that do not validate the token are login/token.php,
1222              // or an auth plugin like auth/ldap/auth.php.
1223              return true;
1224          }
1225  
1226          $currenttoken = self::get_login_token();
1227  
1228          // We need to clean the login token so the old one is not valid again.
1229          self::create_login_token();
1230  
1231          if ($currenttoken !== $token) {
1232              // Fail the login.
1233              return false;
1234          }
1235          return true;
1236      }
1237  
1238      /**
1239       * Get the recent session locks array.
1240       *
1241       * @return array Recent session locks array.
1242       */
1243      public static function get_recent_session_locks() {
1244          global $SESSION;
1245  
1246          if (!isset($SESSION->recentsessionlocks)) {
1247              // This will hold the pages that blocks other page.
1248              $SESSION->recentsessionlocks = array();
1249          }
1250  
1251          return $SESSION->recentsessionlocks;
1252      }
1253  
1254      /**
1255       * Updates the recent session locks.
1256       *
1257       * This function will store session lock info of all the pages visited.
1258       *
1259       * @param array $sessionlock Session lock array.
1260       */
1261      public static function update_recent_session_locks($sessionlock) {
1262          global $CFG, $SESSION;
1263  
1264          if (empty($CFG->debugsessionlock)) {
1265              return;
1266          }
1267  
1268          $readonlysession = defined('READ_ONLY_SESSION') && READ_ONLY_SESSION;
1269          $readonlydebugging = !empty($CFG->enable_read_only_sessions) || !empty($CFG->enable_read_only_sessions_debug);
1270          if ($readonlysession && $readonlydebugging) {
1271              return;
1272          }
1273  
1274          $SESSION->recentsessionlocks = self::get_recent_session_locks();
1275          array_push($SESSION->recentsessionlocks, $sessionlock);
1276  
1277          self::cleanup_recent_session_locks();
1278      }
1279  
1280      /**
1281       * Reset recent session locks array if there is a time gap more than SESSION_RESET_GAP_THRESHOLD.
1282       */
1283      public static function cleanup_recent_session_locks() {
1284          global $SESSION;
1285  
1286          $locks = self::get_recent_session_locks();
1287  
1288          if (count($locks) > self::MAXIMUM_STORED_SESSION_HISTORY) {
1289              // Keep the last MAXIMUM_STORED_SESSION_HISTORY locks and ignore the rest.
1290              $locks = array_slice($locks, -1 * self::MAXIMUM_STORED_SESSION_HISTORY);
1291          }
1292  
1293          if (count($locks) > 2) {
1294              for ($i = count($locks) - 1; $i > 0; $i--) {
1295                  // Calculate the gap between session locks.
1296                  $gap = $locks[$i]['released'] - $locks[$i - 1]['start'];
1297                  if ($gap >= self::SESSION_RESET_GAP_THRESHOLD) {
1298                      // Remove previous locks if the gap is 1 second or more.
1299                      $SESSION->recentsessionlocks = array_slice($locks, $i);
1300                      break;
1301                  }
1302              }
1303          }
1304      }
1305  
1306      /**
1307       * Get the page that blocks other pages at a specific timestamp.
1308       *
1309       * Look for a page whose lock was gained before that timestamp, and released after that timestamp.
1310       *
1311       * @param  float $time Time before session lock starts.
1312       * @return array|null
1313       */
1314      public static function get_locked_page_at($time) {
1315          $recentsessionlocks = self::get_recent_session_locks();
1316          foreach ($recentsessionlocks as $recentsessionlock) {
1317              if ($time >= $recentsessionlock['gained'] &&
1318                  $time <= $recentsessionlock['released']) {
1319                  return $recentsessionlock;
1320              }
1321          }
1322      }
1323  
1324      /**
1325       * Display the page which blocks other pages.
1326       *
1327       * @return string
1328       */
1329      public static function display_blocking_page() {
1330          global $PERF;
1331  
1332          $page = self::get_locked_page_at($PERF->sessionlock['start']);
1333          $output = "Script ".me()." was blocked for ";
1334          $output .= number_format($PERF->sessionlock['wait'], 3);
1335          if ($page != null) {
1336              $output .= " second(s) by script: ";
1337              $output .= $page['url'];
1338          } else {
1339              $output .= " second(s) by an unknown script.";
1340          }
1341  
1342          return $output;
1343      }
1344  
1345      /**
1346       * Get session lock info of the current page.
1347       *
1348       * @return array
1349       */
1350      public static function get_session_lock_info() {
1351          global $PERF;
1352  
1353          if (!isset($PERF->sessionlock)) {
1354              return null;
1355          }
1356          return $PERF->sessionlock;
1357      }
1358  
1359      /**
1360       * Display debugging info about slow and blocked script.
1361       */
1362      public static function sessionlock_debugging() {
1363          global $CFG, $PERF;
1364  
1365          if (!empty($CFG->debugsessionlock)) {
1366              if (isset($PERF->sessionlock['held']) && $PERF->sessionlock['held'] > $CFG->debugsessionlock) {
1367                  debugging("Script ".me()." locked the session for ".number_format($PERF->sessionlock['held'], 3)
1368                  ." seconds, it should close the session using \core\session\manager::write_close().", DEBUG_NORMAL);
1369              }
1370  
1371              if (isset($PERF->sessionlock['wait']) && $PERF->sessionlock['wait'] > $CFG->debugsessionlock) {
1372                  $output = self::display_blocking_page();
1373                  debugging($output, DEBUG_DEVELOPER);
1374              }
1375          }
1376      }
1377  
1378      /**
1379       * Compares two arrays outputs the difference.
1380       *
1381       * Note this does not use array_diff_assoc due to
1382       * the use of stdClasses in Moodle sessions.
1383       *
1384       * @param array $array1
1385       * @param array $array2
1386       * @return array
1387       */
1388      private static function array_session_diff(array $array1, array $array2) : array {
1389          $difference = [];
1390          foreach ($array1 as $key => $value) {
1391              if (!isset($array2[$key])) {
1392                  $difference[$key] = $value;
1393              } else if ($array2[$key] !== $value) {
1394                  $difference[$key] = $value;
1395              }
1396          }
1397  
1398          return $difference;
1399      }
1400  }