MDL-66151 Performance: Session Manager modularisation

Storage of session metadata has moved into the session handler class.
This allows for other classes to fully control session handling and
removes the dependancy on the core sessions database table.

Previously, the standard method of interaction with the
session metadata was direct DB calls; this may break other plugins as there
are now proper APIs available through the session manager.

Co-authored-by: Darren Cocco <[email protected]>
Co-authored-by: Trisha Milan <[email protected]>
Co-authored-by: Andrew Nicols <[email protected]>
This commit is contained in:
Trisha Milan
2024-09-03 13:04:04 +10:00
committed by Matt Porritt
co-authored by Darren Cocco Andrew Nicols
parent 072fb90384
commit e52fbd2f84
30 changed files with 1408 additions and 700 deletions
+204 -176
View File
@@ -14,18 +14,10 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Session manager class.
*
* @package core
* @copyright 2013 Petr Skoda {@link http://skodak.org}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core\session;
defined('MOODLE_INTERNAL') || die();
use core\clock;
use core\di;
use html_writer;
/**
@@ -252,14 +244,14 @@ class manager {
global $CFG, $DB;
if (PHPUNIT_TEST) {
return '\core\session\file';
return \core\tests\session\mock_handler::class;
} else if (!empty($CFG->session_handler_class)) {
return $CFG->session_handler_class;
} else if (!empty($CFG->dbsessions) and $DB->session_lock_supported()) {
return '\core\session\database';
} else if (!empty($CFG->dbsessions) && $DB->session_lock_supported()) {
return database::class;
}
return '\core\session\file';
return file::class;
}
/**
@@ -273,6 +265,9 @@ class manager {
// Find out which handler to use.
$class = self::get_handler_class();
self::$handler = new $class();
if (!self::$handler instanceof \core\session\handler) {
throw new exception("$class must implement the \core\session\handler");
}
}
/**
@@ -419,7 +414,7 @@ class manager {
* @param bool $newsid is this a new session in first http request?
*/
protected static function initialise_user_session($newsid) {
global $CFG, $DB;
global $CFG;
$sid = session_id();
if (!$sid) {
@@ -428,8 +423,8 @@ class manager {
self::init_empty_session($newsid);
return;
}
if (!$record = $DB->get_record('sessions', array('sid'=>$sid), 'id, sid, state, userid, lastip, timecreated, timemodified')) {
$record = self::get_session_by_sid($sid);
if (!isset($record->sid)) {
if (!$newsid) {
if (!empty($_SESSION['USER']->id)) {
// This should not happen, just log it, we MUST not produce any output here!
@@ -456,7 +451,7 @@ class manager {
// Ignore guest and not-logged in timeouts, there is very little risk here.
$timeout = false;
} else if ($record->timemodified < time() - $maxlifetime) {
} else if ($record->timemodified < di::get(clock::class)->time() - $maxlifetime) {
$timeout = true;
$authsequence = get_enabled_auth_plugins(); // Auths, in sequence.
foreach ($authsequence as $authname) {
@@ -474,11 +469,9 @@ class manager {
}
session_regenerate_id(true);
$_SESSION = array();
$DB->delete_records('sessions', array('id'=>$record->id));
self::destroy($record->sid);
} else {
// Update session tracking record.
$update = new \stdClass();
$updated = false;
@@ -493,33 +486,34 @@ class manager {
$updated = true;
}
$time = di::get(clock::class)->time();
$updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency;
if ($record->timemodified == $record->timecreated) {
// Always do first update of existing record.
$update->timemodified = $record->timemodified = time();
$update->timemodified = $record->timemodified = $time;
$updated = true;
} else if ($record->timemodified < time() - $updatefreq) {
} else if ($record->timemodified < $time - $updatefreq) {
// Update the session modified flag only once every 20 seconds.
$update->timemodified = $record->timemodified = time();
$update->timemodified = $record->timemodified = $time;
$updated = true;
}
if ($updated && (!defined('NO_SESSION_UPDATE') || !NO_SESSION_UPDATE)) {
$update->id = $record->id;
$DB->update_record('sessions', $update);
$update->userid = $record->userid;
self::$handler->update_session($update);
}
return;
}
} else {
if ($record) {
// This happens when people switch session handlers...
session_regenerate_id(true);
$_SESSION = array();
$DB->delete_records('sessions', array('id'=>$record->id));
}
} else if (isset($record->sid)) {
// This happens when people switch session handlers...
session_regenerate_id(true);
$_SESSION = [];
self::destroy($record->sid);
}
unset($record);
@@ -551,10 +545,10 @@ class manager {
// Setup $USER and insert the session tracking record.
if ($user) {
self::set_user($user);
self::add_session_record($user->id);
self::add_session($user->id);
} else {
self::init_empty_session($newsid);
self::add_session_record(0);
self::add_session(0);
}
if ($timedout) {
@@ -562,24 +556,44 @@ class manager {
}
}
/**
* Returns a single session record for this session id.
*
* @param string $sid
* @return \stdClass
*/
public static function get_session_by_sid(string $sid): \stdClass {
return self::$handler->get_session_by_sid($sid);
}
/**
* Returns all the session records for this user id.
*
* @param int $userid
* @return array
*/
public static function get_sessions_by_userid(int $userid): array {
return self::$handler->get_sessions_by_userid($userid);
}
/**
* Insert new empty session record.
*
* @param int $userid
* @return \stdClass the new record
*/
protected static function add_session_record($userid) {
global $DB;
$record = new \stdClass();
$record->state = 0;
$record->sid = session_id();
$record->sessdata = null;
$record->userid = $userid;
$record->timecreated = $record->timemodified = time();
$record->firstip = $record->lastip = getremoteaddr();
public static function add_session(int $userid): \stdClass {
return self::$handler->add_session($userid);
}
$record->id = $DB->insert_record('sessions', $record);
return $record;
/**
* Update a session record.
*
* @param \stdClass $record
* @return bool
*/
public static function update_session(\stdClass $record): bool {
return self::$handler->update_session($record);
}
/**
@@ -619,8 +633,8 @@ class manager {
$sid = session_id();
session_regenerate_id(true);
$DB->delete_records('sessions', array('sid'=>$sid));
self::add_session_record($user->id);
self::destroy($sid);
self::add_session($user->id);
// Let enrol plugins deal with new enrolments if necessary.
enrol_check_plugins($user);
@@ -679,9 +693,9 @@ class manager {
// Write new empty session and make sure the old one is deleted.
$sid = session_id();
session_regenerate_id(true);
$DB->delete_records('sessions', array('sid'=>$sid));
self::destroy($sid);
self::init_empty_session();
self::add_session_record($_SESSION['USER']->id); // Do not use $USER here because it may not be set up yet.
self::add_session($_SESSION['USER']->id); // Do not use $USER here because it may not be set up yet.
self::write_close();
}
@@ -814,13 +828,14 @@ class manager {
}
// Note: add sessions->state checking here if it gets implemented.
if (!$record = $DB->get_record('sessions', array('sid' => $sid), 'id, userid, timemodified')) {
$record = self::get_session_by_sid($sid);
if (!isset($record->sid)) {
return false;
}
if (empty($record->userid) or isguestuser($record->userid)) {
// Ignore guest and not-logged-in timeouts, there is very little risk here.
} else if ($record->timemodified < time() - $CFG->sessiontimeout) {
} else if ($record->timemodified < di::get(clock::class)->time() - $CFG->sessiontimeout) {
return false;
}
@@ -842,7 +857,7 @@ class manager {
}
// Note: add sessions->state checking here if it gets implemented.
if (!$record = $DB->get_record('sessions', array('sid' => $sid), 'id, userid, timemodified')) {
if (!$record = self::get_session_by_sid($sid)) {
return ['userid' => 0, 'timeremaining' => $CFG->sessiontimeout];
}
@@ -850,7 +865,10 @@ class manager {
// Ignore guest and not-logged-in timeouts, there is very little risk here.
return ['userid' => 0, 'timeremaining' => $CFG->sessiontimeout];
} else {
return ['userid' => $record->userid, 'timeremaining' => $CFG->sessiontimeout - (time() - $record->timemodified)];
return [
'userid' => $record->userid,
'timeremaining' => $CFG->sessiontimeout - (di::get(clock::class)->time() - $record->timemodified),
];
}
}
@@ -859,64 +877,137 @@ class manager {
* @param string $sid
*/
public static function touch_session($sid) {
global $DB;
// Timeouts depend on core sessions table only, no need to update anything in external stores.
$sql = "UPDATE {sessions} SET timemodified = :now WHERE sid = :sid";
$DB->execute($sql, array('now'=>time(), 'sid'=>$sid));
self::$handler->update_session((object) [
'sid' => $sid,
'timemodified' => di::get(clock::class)->time(),
]);
}
/**
* Terminate all sessions unconditionally.
*
* @return void
* @deprecated since Moodle 4.5 See MDL-66161
* @todo Remove in MDL-81848
*/
public static function kill_all_sessions() {
global $DB;
self::terminate_current();
self::load_handler();
self::$handler->kill_all_sessions();
try {
$DB->delete_records('sessions');
} catch (\dml_exception $ignored) {
// Do not show any warnings - might be during upgrade/installation.
}
#[\core\attribute\deprecated(
replacement: 'destroy_all',
since: '4.5',
)]
public static function kill_all_sessions(): void {
\core\deprecation::emit_deprecation_if_present([self::class, __FUNCTION__]);
self::destroy_all();
}
/**
* Terminate give session unconditionally.
*
* @param string $sid
* @return void
* @deprecated since Moodle 4.5 See MDL-66161
* @todo Remove in MDL-81848
*/
public static function kill_session($sid) {
global $DB;
#[\core\attribute\deprecated(
replacement: 'destroy',
since: '4.5',
)]
public static function kill_session($sid): void {
\core\deprecation::emit_deprecation_if_present([self::class, __FUNCTION__]);
self::destroy($sid);
}
self::load_handler();
if ($sid === session_id()) {
self::write_close();
}
self::$handler->kill_session($sid);
$DB->delete_records('sessions', array('sid'=>$sid));
/**
* Kill sessions of users with disabled plugins.
*
* @param string $pluginname
* @return void
* @deprecated since Moodle 4.5 See MDL-66161
* @todo Remove in MDL-81848
*/
#[\core\attribute\deprecated(
replacement: 'destroy_by_auth_plugin',
since: '4.5',
)]
public static function kill_sessions_for_auth_plugin(string $pluginname): void {
\core\deprecation::emit_deprecation_if_present([self::class, __FUNCTION__]);
self::destroy_by_auth_plugin($pluginname);
}
/**
* Terminate all sessions of given user unconditionally.
*
* @param int $userid
* @param string $keepsid keep this sid if present
* @deprecated since Moodle 4.5 See MDL-66161
* @todo Remove in MDL-81848
*/
#[\core\attribute\deprecated(
replacement: 'destroy_user_sessions',
since: '4.5',
)]
public static function kill_user_sessions($userid, $keepsid = null) {
\core\deprecation::emit_deprecation_if_present([self::class, __FUNCTION__]);
self::destroy_user_sessions($userid, $keepsid);
}
/**
* Destroy all sessions for a given plugin.
* Typically used when a plugin is disabled or uninstalled, so all sessions (users) for that plugin are logged out.
*
* @param string $pluginname Auth plugin name.
*/
public static function destroy_by_auth_plugin(string $pluginname): void {
self::$handler->destroy_by_auth_plugin($pluginname);
}
/**
* Destroy all sessions, and delete all the session data.
*
* @return bool
*/
public static function destroy_all(): bool {
self::terminate_current();
self::load_handler();
try {
$result = self::$handler->destroy_all();
} catch (\moodle_exception $ignored) {
// Do not show any warnings - might be during upgrade/installation.
$result = true;
}
return $result;
}
/**
* Destroy a specific session and delete this session record for this session id.
*
* @param string $id
* @return bool
*/
public static function destroy(string $id): bool {
self::load_handler();
if ($id === session_id()) {
self::write_close();
}
return self::$handler->destroy($id);
}
/**
* Destroy all sessions of given user unconditionally.
* @param int $userid
* @param string $keepsid keep this sid if present
*/
public static function kill_user_sessions($userid, $keepsid = null) {
global $DB;
$sessions = $DB->get_records('sessions', array('userid'=>$userid), 'id DESC', 'id, sid');
public static function destroy_user_sessions($userid, $keepsid = null) {
$sessions = self::get_sessions_by_userid($userid);
foreach ($sessions as $session) {
if ($keepsid and $keepsid === $session->sid) {
continue;
}
self::kill_session($session->sid);
self::destroy($session->sid);
}
}
@@ -949,30 +1040,35 @@ class manager {
return;
}
$count = $DB->count_records('sessions', array('userid' => $userid));
$sessions = self::get_sessions_by_userid($userid);
$count = count($sessions);
if ($count <= $CFG->limitconcurrentlogins) {
return;
}
$i = 0;
$select = "userid = :userid";
$params = array('userid' => $userid);
if ($sid) {
if ($DB->record_exists('sessions', array('sid' => $sid, 'userid' => $userid))) {
$select .= " AND sid <> :sid";
$params['sid'] = $sid;
$i = 1;
foreach ($sessions as $key => $session) {
if ($session->sid == $sid && $session->userid == $userid) {
$i = 1;
unset($sessions[$key]);
}
}
}
$sessions = $DB->get_records_select('sessions', $select, $params, 'timecreated DESC', 'id, sid');
// Order records by timecreated DESC.
usort($sessions, function($a, $b){
return $b->timecreated <=> $a->timecreated;
});
foreach ($sessions as $session) {
$i++;
if ($i <= $CFG->limitconcurrentlogins) {
continue;
}
self::kill_session($session->sid);
self::destroy($session->sid);
}
}
@@ -1006,86 +1102,18 @@ class manager {
/**
* Periodic timed-out session cleanup.
*
* @param int $maxlifetime Sessions that have not updated for the last max_lifetime seconds will be removed.
* @return void
*/
public static function gc() {
global $CFG, $DB;
public static function gc(int $maxlifetime = 0): void {
global $CFG;
// This may take a long time...
\core_php_time_limit::raise();
$maxlifetime = $CFG->sessiontimeout;
try {
// Kill all sessions of deleted and suspended users without any hesitation.
$rs = $DB->get_recordset_select('sessions', "userid IN (SELECT id FROM {user} WHERE deleted <> 0 OR suspended <> 0)", array(), 'id DESC', 'id, sid');
foreach ($rs as $session) {
self::kill_session($session->sid);
}
$rs->close();
// Kill sessions of users with disabled plugins.
$authsequence = get_enabled_auth_plugins();
$authsequence = array_flip($authsequence);
unset($authsequence['nologin']); // No login means user cannot login.
$authsequence = array_flip($authsequence);
list($notplugins, $params) = $DB->get_in_or_equal($authsequence, SQL_PARAMS_QM, '', false);
$rs = $DB->get_recordset_select('sessions', "userid IN (SELECT id FROM {user} WHERE auth $notplugins)", $params, 'id DESC', 'id, sid');
foreach ($rs as $session) {
self::kill_session($session->sid);
}
$rs->close();
// Now get a list of time-out candidates - real users only.
$sql = "SELECT u.*, s.sid, s.timecreated AS s_timecreated, s.timemodified AS s_timemodified
FROM {user} u
JOIN {sessions} s ON s.userid = u.id
WHERE s.timemodified < :purgebefore AND u.id <> :guestid";
$params = array('purgebefore' => (time() - $maxlifetime), 'guestid'=>$CFG->siteguest);
$authplugins = array();
foreach ($authsequence as $authname) {
$authplugins[$authname] = get_auth_plugin($authname);
}
$rs = $DB->get_recordset_sql($sql, $params);
foreach ($rs as $user) {
foreach ($authplugins as $authplugin) {
/** @var \auth_plugin_base $authplugin*/
if ($authplugin->ignore_timeout_hook($user, $user->sid, $user->s_timecreated, $user->s_timemodified)) {
continue 2;
}
}
self::kill_session($user->sid);
}
$rs->close();
// Delete expired sessions for guest user account, give them larger timeout, there is no security risk here.
$params = array('purgebefore' => (time() - ($maxlifetime * 5)), 'guestid'=>$CFG->siteguest);
$rs = $DB->get_recordset_select('sessions', 'userid = :guestid AND timemodified < :purgebefore', $params, 'id DESC', 'id, sid');
foreach ($rs as $session) {
self::kill_session($session->sid);
}
$rs->close();
// Delete expired sessions for userid = 0 (not logged in), better kill them asap to release memory.
$params = array('purgebefore' => (time() - $maxlifetime));
$rs = $DB->get_recordset_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params, 'id DESC', 'id, sid');
foreach ($rs as $session) {
self::kill_session($session->sid);
}
$rs->close();
// Cleanup letfovers from the first browser access because it may set multiple cookies and then use only one.
$params = array('purgebefore' => (time() - 60*3));
$rs = $DB->get_recordset_select('sessions', 'userid = 0 AND timemodified = timecreated AND timemodified < :purgebefore', $params, 'id ASC', 'id, sid');
foreach ($rs as $session) {
self::kill_session($session->sid);
}
$rs->close();
} catch (\Exception $ex) {
debugging('Error gc-ing sessions: '.$ex->getMessage(), DEBUG_NORMAL, $ex->getTrace());
// If max lifetime is not provided, use the default session timeout.
if ($maxlifetime == 0) {
$maxlifetime = $CFG->sessiontimeout;
}
self::$handler->gc($maxlifetime);
}
/**
@@ -1214,7 +1242,7 @@ class manager {
$state = [
'token' => random_string(32),
'created' => time() // Server time - not user time.
'created' => di::get(clock::class)->time(), // Server time - not user time.
];
if (!isset($SESSION->logintoken)) {
@@ -1254,7 +1282,7 @@ class manager {
}
// Check token lifespan.
if ($state['created'] < (time() - $CFG->sessiontimeout)) {
if ($state['created'] < (di::get(clock::class)->time() - $CFG->sessiontimeout)) {
$state = self::create_login_token();
}