Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   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   * Database based session handler.
  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  /**
  30   * Database based session handler.
  31   *
  32   * @package    core
  33   * @copyright  2013 Petr Skoda {@link http://skodak.org}
  34   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  35   */
  36  class database extends handler {
  37      /** @var \stdClass $record session record */
  38      protected $recordid = null;
  39  
  40      /** @var \moodle_database $database session database */
  41      protected $database = null;
  42  
  43      /** @var bool $failed session read/init failed, do not write back to DB */
  44      protected $failed = false;
  45  
  46      /** @var string $lasthash hash of the session data content */
  47      protected $lasthash = null;
  48  
  49      /** @var int $acquiretimeout how long to wait for session lock */
  50      protected $acquiretimeout = 120;
  51  
  52      /**
  53       * Create new instance of handler.
  54       */
  55      public function __construct() {
  56          global $DB, $CFG;
  57          // Note: we store the reference here because we need to modify database in shutdown handler.
  58          $this->database = $DB;
  59  
  60          if (!empty($CFG->session_database_acquire_lock_timeout)) {
  61              $this->acquiretimeout = (int)$CFG->session_database_acquire_lock_timeout;
  62          }
  63      }
  64  
  65      /**
  66       * Init session handler.
  67       */
  68      public function init() {
  69          if (!$this->database->session_lock_supported()) {
  70              throw new exception('sessionhandlerproblem', 'error', '', null, 'Database does not support session locking');
  71          }
  72  
  73          $result = session_set_save_handler(array($this, 'handler_open'),
  74              array($this, 'handler_close'),
  75              array($this, 'handler_read'),
  76              array($this, 'handler_write'),
  77              array($this, 'handler_destroy'),
  78              array($this, 'handler_gc'));
  79          if (!$result) {
  80              throw new exception('dbsessionhandlerproblem', 'error');
  81          }
  82      }
  83  
  84      /**
  85       * Check the backend contains data for this session id.
  86       *
  87       * Note: this is intended to be called from manager::session_exists() only.
  88       *
  89       * @param string $sid
  90       * @return bool true if session found.
  91       */
  92      public function session_exists($sid) {
  93          // It was already checked in the calling code that the record in sessions table exists.
  94          return true;
  95      }
  96  
  97      /**
  98       * Kill all active sessions, the core sessions table is
  99       * purged afterwards.
 100       */
 101      public function kill_all_sessions() {
 102          // Nothing to do, the sessions table is cleared from core.
 103          return;
 104      }
 105  
 106      /**
 107       * Kill one session, the session record is removed afterwards.
 108       * @param string $sid
 109       */
 110      public function kill_session($sid) {
 111          // Nothing to do, the sessions table is purged afterwards.
 112          return;
 113      }
 114  
 115      /**
 116       * Open session handler.
 117       *
 118       * {@see http://php.net/manual/en/function.session-set-save-handler.php}
 119       *
 120       * @param string $save_path
 121       * @param string $session_name
 122       * @return bool success
 123       */
 124      public function handler_open($save_path, $session_name) {
 125          // Note: we use the already open database.
 126          return true;
 127      }
 128  
 129      /**
 130       * Close session handler.
 131       *
 132       * {@see http://php.net/manual/en/function.session-set-save-handler.php}
 133       *
 134       * @return bool success
 135       */
 136      public function handler_close() {
 137          if ($this->recordid) {
 138              try {
 139                  $this->database->release_session_lock($this->recordid);
 140              } catch (\Exception $ex) {
 141                  // Ignore any problems.
 142              }
 143          }
 144          $this->recordid = null;
 145          $this->lasthash = null;
 146          return true;
 147      }
 148  
 149      /**
 150       * Read session handler.
 151       *
 152       * {@see http://php.net/manual/en/function.session-set-save-handler.php}
 153       *
 154       * @param string $sid
 155       * @return string
 156       */
 157      public function handler_read($sid) {
 158          try {
 159              if (!$record = $this->database->get_record('sessions', array('sid'=>$sid), 'id')) {
 160                  // Let's cheat and skip locking if this is the first access,
 161                  // do not create the record here, let the manager do it after session init.
 162                  $this->failed = false;
 163                  $this->recordid = null;
 164                  $this->lasthash = sha1('');
 165                  return '';
 166              }
 167              if ($this->recordid and $this->recordid != $record->id) {
 168                  error_log('Second session read with different record id detected, cannot read session');
 169                  $this->failed = true;
 170                  $this->recordid = null;
 171                  return '';
 172              }
 173              if (!$this->recordid) {
 174                  // Lock session if exists and not already locked.
 175                  if ($this->requires_write_lock()) {
 176                      $this->database->get_session_lock($record->id, $this->acquiretimeout);
 177                  }
 178                  $this->recordid = $record->id;
 179              }
 180          } catch (\dml_sessionwait_exception $ex) {
 181              // This is a fatal error, better inform users.
 182              // It should not happen very often - all pages that need long time to execute
 183              // should close session immediately after access control checks.
 184              error_log('Cannot obtain session lock for sid: '.$sid);
 185              $this->failed = true;
 186              throw $ex;
 187  
 188          } catch (\Exception $ex) {
 189              // Do not rethrow exceptions here, this should not happen.
 190              error_log('Unknown exception when starting database session : '.$sid.' - '.$ex->getMessage());
 191              $this->failed = true;
 192              $this->recordid = null;
 193              return '';
 194          }
 195  
 196          // Finally read the full session data because we know we have the lock now.
 197          if (!$record = $this->database->get_record('sessions', array('id'=>$record->id), 'id, sessdata')) {
 198              // Ignore - something else just deleted the session record.
 199              $this->failed = true;
 200              $this->recordid = null;
 201              return '';
 202          }
 203          $this->failed = false;
 204  
 205          if (is_null($record->sessdata)) {
 206              $data = '';
 207              $this->lasthash = sha1('');
 208          } else {
 209              $data = base64_decode($record->sessdata);
 210              $this->lasthash = sha1($record->sessdata);
 211          }
 212  
 213          return $data;
 214      }
 215  
 216      /**
 217       * Write session handler.
 218       *
 219       * {@see http://php.net/manual/en/function.session-set-save-handler.php}
 220       *
 221       * NOTE: Do not write to output or throw any exceptions!
 222       *       Hopefully the next page is going to display nice error or it recovers...
 223       *
 224       * @param string $sid
 225       * @param string $session_data
 226       * @return bool success
 227       */
 228      public function handler_write($sid, $session_data) {
 229          if ($this->failed) {
 230              // Do not write anything back - we failed to start the session properly.
 231              return false;
 232          }
 233  
 234          $sessdata = base64_encode($session_data); // There might be some binary mess :-(
 235          $hash = sha1($sessdata);
 236  
 237          if ($hash === $this->lasthash) {
 238              return true;
 239          }
 240  
 241          try {
 242              if ($this->recordid) {
 243                  $this->database->set_field('sessions', 'sessdata', $sessdata, array('id'=>$this->recordid));
 244              } else {
 245                  // This happens in the first request when session record was just created in manager.
 246                  $this->database->set_field('sessions', 'sessdata', $sessdata, array('sid'=>$sid));
 247              }
 248          } catch (\Exception $ex) {
 249              // Do not rethrow exceptions here, this should not happen.
 250              error_log('Unknown exception when writing database session data : '.$sid.' - '.$ex->getMessage());
 251          }
 252  
 253          return true;
 254      }
 255  
 256      /**
 257       * Destroy session handler.
 258       *
 259       * {@see http://php.net/manual/en/function.session-set-save-handler.php}
 260       *
 261       * @param string $sid
 262       * @return bool success
 263       */
 264      public function handler_destroy($sid) {
 265          if (!$session = $this->database->get_record('sessions', array('sid'=>$sid), 'id, sid')) {
 266              if ($sid == session_id()) {
 267                  $this->recordid = null;
 268                  $this->lasthash = null;
 269              }
 270              return true;
 271          }
 272  
 273          if ($this->recordid and $session->id == $this->recordid) {
 274              try {
 275                  $this->database->release_session_lock($this->recordid);
 276              } catch (\Exception $ex) {
 277                  // Ignore problems.
 278              }
 279              $this->recordid = null;
 280              $this->lasthash = null;
 281          }
 282  
 283          $this->database->delete_records('sessions', array('id'=>$session->id));
 284  
 285          return true;
 286      }
 287  
 288      /**
 289       * GC session handler.
 290       *
 291       * {@see http://php.net/manual/en/function.session-set-save-handler.php}
 292       *
 293       * @param int $ignored_maxlifetime moodle uses special timeout rules
 294       * @return bool success
 295       */
 296      public function handler_gc($ignored_maxlifetime) {
 297          // This should do something only if cron is not running properly...
 298          if (!$stalelifetime = ini_get('session.gc_maxlifetime')) {
 299              return true;
 300          }
 301          $params = array('purgebefore' => (time() - $stalelifetime));
 302          $this->database->delete_records_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params);
 303          return true;
 304      }
 305  }