Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

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