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 <moodle@darren.cocco.id.au>
Co-authored-by: Trisha Milan <trishamilan@catalyst-au.net>
Co-authored-by: Andrew Nicols <andrew@nicols.co.uk>
This commit is contained in:
Trisha Milan 2022-08-04 11:11:07 +10:00 committed by Matt Porritt
parent 072fb90384
commit e52fbd2f84
30 changed files with 1408 additions and 700 deletions

View File

@ -0,0 +1,15 @@
issueNumber: MDL-66151
notes:
core_role:
- message: |
Move all session management to the \core\session\manager class.
This removes the dependancy to use the "sessions" table.
Session management plugins (like redis) now need to inherit
the base \core\session\handler class which implements
SessionHandlerInterface and override methods as required.
The following methods in \core\session\manager have been deprecated:
* kill_all_sessions use destroy_all instead
* kill_session use destroy instead
* kill_sessions_for_auth_plugin use destroy_by_auth_plugin instead
* kill_user_sessions use destroy_user_sessions instead
type: improved

View File

@ -79,7 +79,7 @@ if ($form->is_cancelled()) {
$DB->set_field('tool_mfa', 'revoked', 1, ['userid' => $user->id, 'factor' => 'email']);
// Remotely logout all sessions for user.
$manager = \core\session\manager::kill_user_sessions($instance->userid);
\core\session\manager::destroy_user_sessions($instance->userid);
// Log event.
$ip = $instance->createdfromip;

View File

@ -931,7 +931,7 @@ class process {
}
if ($dologout) {
\core\session\manager::kill_user_sessions($existinguser->id);
\core\session\manager::destroy_user_sessions($existinguser->id);
}
} else {

View File

@ -131,7 +131,7 @@
if (!is_siteadmin($user) and $USER->id != $user->id and $user->suspended != 1) {
$user->suspended = 1;
// Force logout.
\core\session\manager::kill_user_sessions($user->id);
\core\session\manager::destroy_user_sessions($user->id);
user_update_user($user, false);
}
}

View File

@ -839,7 +839,7 @@ class auth_plugin_ldap extends auth_plugin_base {
$updateuser->suspended = 1;
user_update_user($updateuser, false);
echo "\t"; print_string('auth_dbsuspenduser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id)); echo "\n";
\core\session\manager::kill_user_sessions($user->id);
\core\session\manager::destroy_user_sessions($user->id);
}
} else {
print_string('nouserentriestoremove', 'auth_ldap');

View File

@ -868,7 +868,7 @@ class auth_plugin_mnet extends auth_plugin_base {
array('useragent'=>$useragent, 'userid'=>$userid));
if (isset($remoteclient) && isset($remoteclient->id)) {
\core\session\manager::kill_user_sessions($userid);
\core\session\manager::destroy_user_sessions($userid);
}
return $returnstring;
}
@ -888,7 +888,7 @@ class auth_plugin_mnet extends auth_plugin_base {
$session = $DB->get_record('mnet_session', array('username'=>$username, 'mnethostid'=>$remoteclient->id, 'useragent'=>$useragent));
$DB->delete_records('mnet_session', array('username'=>$username, 'mnethostid'=>$remoteclient->id, 'useragent'=>$useragent));
if (false != $session) {
\core\session\manager::kill_session($session->session_id);
\core\session\manager::destroy($session->session_id);
return true;
}
return false;
@ -905,7 +905,7 @@ class auth_plugin_mnet extends auth_plugin_base {
global $CFG;
if (is_array($sessionArray)) {
while($session = array_pop($sessionArray)) {
\core\session\manager::kill_session($session->session_id);
\core\session\manager::destroy($session->session_id);
}
return true;
}

View File

@ -98,7 +98,7 @@ class helper {
// If there is a match, kill the session.
if ($usersession['SESSION']->shibboleth_session_id == trim($spsessionid)) {
// Delete this user's sessions.
\core\session\manager::kill_user_sessions($session->userid);
\core\session\manager::destroy_user_sessions($session->userid);
}
}
}

View File

@ -604,7 +604,8 @@ class cachestore_redis extends store implements
* @return bool True if the lock was acquired, false if it was not.
*/
public function acquire_lock($key, $ownerid) {
$timelimit = time() + $this->lockwait;
$clock = \core\di::get(\core\clock::class);
$timelimit = $clock->time() + $this->lockwait;
do {
// If the key doesn't already exist, grab it and return true.
if ($this->redis->setnx($key, $ownerid)) {
@ -620,7 +621,7 @@ class cachestore_redis extends store implements
}
// Wait 1 second then retry.
sleep(1);
} while (time() < $timelimit);
} while ($clock->time() < $timelimit);
return false;
}

View File

@ -1163,11 +1163,11 @@ EOF;
if (empty($sid)) {
throw new coding_exception('failed to get moodle session');
}
$userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]);
if (empty($userid)) {
throw new coding_exception('failed to get user from seession id '.$sid);
$session = \core\session\manager::get_session_by_sid($sid);
if (empty($session->userid)) {
throw new coding_exception('failed to get user from session id: '.$sid);
}
return $DB->get_record('user', ['id' => $userid]);
return $DB->get_record('user', ['id' => $session->userid]);
}
/**

View File

@ -87,6 +87,7 @@ class auth extends base {
$new = implode(',', array_flip($plugins));
add_to_config_log('auth', $CFG->auth, $new, 'core');
set_config('auth', $new);
\core\session\manager::destroy_by_auth_plugin($pluginname);
// Remove stale sessions.
\core\session\manager::gc();
// Reset caches.
@ -160,6 +161,7 @@ class auth extends base {
$value = implode(',', $auths);
add_to_config_log('auth', $CFG->auth, $value, 'core');
set_config('auth', $value);
\core\session\manager::destroy_by_auth_plugin($this->name);
}
if (!empty($CFG->registerauth) and $CFG->registerauth === $this->name) {

View File

@ -14,14 +14,6 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Database based session handler.
*
* @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;
use SessionHandlerInterface;
@ -34,7 +26,8 @@ use SessionHandlerInterface;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class database extends handler implements SessionHandlerInterface {
/** @var \stdClass $record session record */
/** @var int $record session record */
protected $recordid = null;
/** @var \moodle_database $database session database */
@ -62,9 +55,7 @@ class database extends handler implements SessionHandlerInterface {
}
}
/**
* Init session handler.
*/
#[\Override]
public function init() {
if (!$this->database->session_lock_supported()) {
throw new exception('sessionhandlerproblem', 'error', '', null, 'Database does not support session locking');
@ -76,35 +67,36 @@ class database extends handler implements SessionHandlerInterface {
}
}
/**
* Check the backend contains data for this session id.
*
* Note: this is intended to be called from manager::session_exists() only.
*
* @param string $sid
* @return bool true if session found.
*/
#[\Override]
public function session_exists($sid) {
// It was already checked in the calling code that the record in sessions table exists.
return true;
}
/**
* Kill all active sessions, the core sessions table is
* purged afterwards.
*/
public function kill_all_sessions() {
// Nothing to do, the sessions table is cleared from core.
return;
}
#[\Override]
public function destroy(string $id): bool {
if (!$session = $this->get_session_by_sid($id)) {
if ($id == session_id()) {
$this->recordid = null;
$this->lasthash = null;
}
return true;
}
/**
* Kill one session, the session record is removed afterwards.
* @param string $sid
*/
public function kill_session($sid) {
// Nothing to do, the sessions table is purged afterwards.
return;
if ($this->recordid && ($session->id == $this->recordid)) {
try {
$this->database->release_session_lock($this->recordid);
} catch (\Exception $ex) {
// Log and ignore any problems.
mtrace('Failed to release session lock: '.$ex->getMessage());
}
$this->recordid = null;
$this->lasthash = null;
}
$this->database->delete_records('sessions', ['id' => $session->id]);
return true;
}
/**
@ -151,7 +143,7 @@ class database extends handler implements SessionHandlerInterface {
*/
public function read(string $sid): string|false {
try {
if (!$record = $this->database->get_record('sessions', array('sid'=>$sid), 'id')) {
if (!$record = $this->get_session_by_sid($sid)) {
// Let's cheat and skip locking if this is the first access,
// do not create the record here, let the manager do it after session init.
$this->failed = false;
@ -252,56 +244,4 @@ class database extends handler implements SessionHandlerInterface {
return true;
}
/**
* Destroy session handler.
*
* {@see http://php.net/manual/en/function.session-set-save-handler.php}
*
* @param string $id
* @return bool success
*/
public function destroy(string $id): bool {
if (!$session = $this->database->get_record('sessions', ['sid' => $id], 'id, sid')) {
if ($id == session_id()) {
$this->recordid = null;
$this->lasthash = null;
}
return true;
}
if ($this->recordid && ($session->id == $this->recordid)) {
try {
$this->database->release_session_lock($this->recordid);
} catch (\Exception $ex) {
// Ignore problems.
}
$this->recordid = null;
$this->lasthash = null;
}
$this->database->delete_records('sessions', ['id' => $session->id]);
return true;
}
/**
* GC session handler.
*
* {@see http://php.net/manual/en/function.session-set-save-handler.php}
*
* @param int $max_lifetime moodle uses special timeout rules
* @return bool success
*/
// phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameUnderscore
public function gc(int $max_lifetime): int|false {
// This should do something only if cron is not running properly...
if (!$stalelifetime = ini_get('session.gc_maxlifetime')) {
return false;
}
$params = ['purgebefore' => (time() - $stalelifetime)];
$count = $this->database->count_records_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params);
$this->database->delete_records_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params);
return $count;
}
}

View File

@ -14,18 +14,8 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* File based session handler.
*
* @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();
/**
* File based session handler.
*
@ -34,6 +24,7 @@ defined('MOODLE_INTERNAL') || die();
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class file extends handler {
/** @var string session dir */
protected $sessiondir;
@ -50,9 +41,7 @@ class file extends handler {
}
}
/**
* Init session handler.
*/
#[\Override]
public function init() {
if (preg_match('/^[0-9]+;/', $this->sessiondir)) {
throw new exception('sessionhandlerproblem', 'error', '', null, 'Multilevel session directories are not supported');
@ -75,14 +64,7 @@ class file extends handler {
ini_set('session.save_path', $this->sessiondir);
}
/**
* Check the backend contains data for this session id.
*
* Note: this is intended to be called from manager::session_exists() only.
*
* @param string $sid
* @return bool true if session found.
*/
#[\Override]
public function session_exists($sid) {
$sid = clean_param($sid, PARAM_FILE);
if (!$sid) {
@ -92,30 +74,28 @@ class file extends handler {
return file_exists($sessionfile);
}
/**
* Kill all active sessions, the core sessions table is
* purged afterwards.
*/
public function kill_all_sessions() {
#[\Override]
public function destroy_all(): bool {
if (is_dir($this->sessiondir)) {
foreach (glob("$this->sessiondir/sess_*") as $filename) {
@unlink($filename);
}
}
return true;
}
/**
* Kill one session, the session record is removed afterwards.
* @param string $sid
*/
public function kill_session($sid) {
$sid = clean_param($sid, PARAM_FILE);
#[\Override]
public function destroy(string $id): bool {
$sid = clean_param($id, PARAM_FILE);
if (!$sid) {
return;
return false;
}
$sessionfile = "$this->sessiondir/sess_$sid";
if (file_exists($sessionfile)) {
@unlink($sessionfile);
}
return true;
}
}

View File

@ -14,17 +14,11 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Session handler base.
*
* @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 stdClass;
/**
* Session handler base.
@ -79,6 +73,220 @@ abstract class handler {
$this->requireswritelock = $requireswritelock;
}
/**
* Returns all session records.
*
* @return \Iterator
*/
public function get_all_sessions(): \Iterator {
global $DB;
$rs = $DB->get_recordset('sessions');
foreach ($rs as $row) {
yield $row;
}
$rs->close();
}
/**
* Returns a single session record for this session id.
*
* @param string $sid
* @return stdClass
*/
public function get_session_by_sid(string $sid): stdClass {
global $DB;
return $DB->get_record('sessions', ['sid' => $sid]) ?: new stdClass();
}
/**
* Returns all the session records for this user id.
*
* @param int $userid
* @return array
*/
public function get_sessions_by_userid(int $userid): array {
global $DB;
return $DB->get_records('sessions', ['userid' => $userid]);
}
/**
* Insert new empty session record.
*
* @param int $userid
* @return stdClass the new record
*/
public function add_session(int $userid): stdClass {
global $DB;
$record = new stdClass();
$record->state = 0;
$record->sid = session_id();
$record->sessdata = null;
$record->userid = $userid;
$record->timecreated = $record->timemodified = di::get(clock::class)->time();
$record->firstip = $record->lastip = getremoteaddr();
$record->id = $DB->insert_record('sessions', $record);
return $record;
}
/**
* Update a session record.
*
* @param stdClass $record
* @return bool
*/
public function update_session(stdClass $record): bool {
global $DB;
if (!isset($record->id) && isset($record->sid)) {
$record->id = $DB->get_field('sessions', 'id', ['sid' => $record->sid]);
}
return $DB->update_record('sessions', $record);
}
/**
* Destroy a specific session and delete this session record for this session id.
*
* @param string $id session id
* @return bool
*/
public function destroy(string $id): bool {
global $DB;
return $DB->delete_records('sessions', ['sid' => $id]);
}
/**
* Destroy all sessions, and delete all the session data.
*
* @return bool
*/
public function destroy_all(): bool {
global $DB;
return $DB->delete_records('sessions');
}
/**
* Clean up expired sessions.
*
* @param int $purgebefore Sessions that have not updated for the last purgebefore timestamp will be removed.
* @param int $userid
*/
protected function destroy_expired_user_sessions(int $purgebefore, int $userid): void {
$sessions = $this->get_sessions_by_userid($userid);
foreach ($sessions as $session) {
if ($session->timemodified < $purgebefore) {
$this->destroy($session->sid);
}
}
}
/**
* Clean up all expired sessions.
*
* @param int $purgebefore
*/
protected function destroy_all_expired_sessions(int $purgebefore): void {
global $DB, $CFG;
$authsequence = get_enabled_auth_plugins();
$authsequence = array_flip($authsequence);
unset($authsequence['nologin']); // No login means user cannot login.
$authsequence = array_flip($authsequence);
$authplugins = [];
foreach ($authsequence as $authname) {
$authplugins[$authname] = get_auth_plugin($authname);
}
$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 = ['purgebefore' => $purgebefore, 'guestid' => $CFG->siteguest];
$rs = $DB->get_recordset_sql($sql, $params);
foreach ($rs as $user) {
foreach ($authplugins as $authplugin) {
if ($authplugin->ignore_timeout_hook($user, $user->sid, $user->s_timecreated, $user->s_timemodified)) {
continue 2;
}
}
$this->destroy($user->sid);
}
$rs->close();
}
/**
* 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 function destroy_by_auth_plugin(string $pluginname): void {
global $DB;
$rs = $DB->get_recordset('user', ['auth' => $pluginname], 'id ASC', 'id');
foreach ($rs as $user) {
$sessions = $this->get_sessions_by_userid($user->id);
foreach ($sessions as $session) {
$this->destroy($session->sid);
}
}
$rs->close();
}
// phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameUnderscore
/**
* Periodic timed-out session cleanup.
*
* @param int $max_lifetime Sessions that have not updated for the last max_lifetime seconds will be removed.
* @return int|false Number of deleted sessions or false if an error occurred.
*/
public function gc(int $max_lifetime = 0): int|false {
global $CFG;
// This may take a long time.
\core_php_time_limit::raise();
if ($max_lifetime === 0) {
$max_lifetime = $CFG->sessiontimeout;
}
try {
// Delete expired sessions for guest user account, give them larger timeout, there is no security risk here.
$purgebefore = di::get(clock::class)->time() - ($max_lifetime * 5);
$this->destroy_expired_user_sessions($purgebefore, $CFG->siteguest);
// Delete expired sessions for userid = 0 (not logged in), better kill them asap to release memory.
$purgebefore = di::get(clock::class)->time() - $max_lifetime;
$this->destroy_expired_user_sessions($purgebefore, 0);
// Clean up expired sessions for real users only.
$this->destroy_all_expired_sessions($purgebefore);
// Cleanup leftovers from the first browser access because it may set multiple cookies and then use only one.
$purgebefore = di::get(clock::class)->time() - (60 * 3);
$sessions = $this->get_sessions_by_userid(0);
foreach ($sessions as $session) {
if ($session->timemodified == $session->timecreated && $session->timemodified < $purgebefore) {
$this->destroy($session->sid);
}
}
} catch (\Exception $ex) {
debugging('Error gc-ing sessions: '.$ex->getMessage(), DEBUG_NORMAL, $ex->getTrace());
}
return 0;
}
// phpcs:enable
/**
* Has this session been opened with a writelock? Your handler should call this during
* start() if you support read-only sessions.
@ -102,16 +310,4 @@ abstract class handler {
* @return bool true if session found.
*/
abstract public function session_exists($sid);
/**
* Kill all active sessions, the core sessions table is
* purged afterwards.
*/
abstract public function kill_all_sessions();
/**
* Kill one session, the session record is removed afterwards.
* @param string $sid
*/
abstract public function kill_session($sid);
}

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();
}

View File

@ -14,18 +14,8 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Memcached based session handler.
*
* @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();
/**
* Memcached based session handler.
*
@ -96,10 +86,7 @@ class memcached extends handler {
}
}
/**
* Start the session.
* @return bool success
*/
#[\Override]
public function start() {
ini_set('memcached.sess_locking', $this->requires_write_lock() ? '1' : '0');
@ -132,9 +119,7 @@ class memcached extends handler {
return $result;
}
/**
* Init session handler.
*/
#[\Override]
public function init() {
if (!extension_loaded('memcached')) {
throw new exception('sessionhandlerproblem', 'error', '', null, 'memcached extension is not loaded');
@ -178,14 +163,7 @@ class memcached extends handler {
}
/**
* Check the backend contains data for this session id.
*
* Note: this is intended to be called from manager::session_exists() only.
*
* @param string $sid
* @return bool true if session found.
*/
#[\Override]
public function session_exists($sid) {
if (!$this->servers) {
return false;
@ -209,14 +187,11 @@ class memcached extends handler {
return false;
}
/**
* Kill all active sessions, the core sessions table is
* purged afterwards.
*/
public function kill_all_sessions() {
#[\Override]
public function destroy_all(): bool {
global $DB;
if (!$this->servers) {
return;
return false;
}
// Go through the list of all servers because
@ -245,15 +220,14 @@ class memcached extends handler {
foreach ($memcacheds as $memcached) {
$memcached->quit();
}
return true;
}
/**
* Kill one session, the session record is removed afterwards.
* @param string $sid
*/
public function kill_session($sid) {
#[\Override]
public function destroy(string $id): bool {
if (!$this->servers) {
return;
return false;
}
// Go through the list of all servers because
@ -264,9 +238,11 @@ class memcached extends handler {
list($host, $port) = $server;
$memcached = new \Memcached();
$memcached->addServer($host, $port);
$memcached->delete($this->prefix . $sid);
$memcached->delete($this->prefix . $id);
$memcached->quit();
}
return true;
}
/**

View File

@ -14,29 +14,21 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Redis based session handler.
*
* @package core
* @copyright 2015 Russell Smith <mr-russ@smith2001.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core\session;
use RedisException;
use coding_exception;
use core\di;
use core\clock;
use RedisCluster;
use RedisClusterException;
use RedisException;
use SessionHandlerInterface;
/**
* Redis based session handler.
*
* The default Redis session handler does not handle locking in 2.2.7, so we have written a php session handler
* that uses locking. The places where locking is used was modeled from the memcached code that is used in Moodle
* https://github.com/php-memcached-dev/php-memcached/blob/master/php_memcached_session.c
*
* @package core
* @copyright 2016 Russell Smith
* @copyright Russell Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class redis extends handler implements SessionHandlerInterface {
@ -53,14 +45,11 @@ class redis extends handler implements SessionHandlerInterface {
*/
const COMPRESSION_ZSTD = 'zstd';
/**
* Minimum version of the Redis extension required.
*/
public const REDIS_EXTENSION_MIN_VERSION = '2.2.4';
/**
* Minimum version of the Redis extension required.
*/
private const REDIS_SERVER_MIN_VERSION = '2.6.12';
/** @var string Minimum server version */
const REDIS_MIN_SERVER_VERSION = "5.0.0";
/** @var string Minimum extension version */
const REDIS_MIN_EXTENSION_VERSION = "5.1.0";
/** @var array $host save_path string */
protected array $host = [];
@ -74,6 +63,13 @@ class redis extends handler implements SessionHandlerInterface {
protected $database = 0;
/** @var array $servers list of servers parsed from save_path */
protected $prefix = '';
/** @var string $sessionkeyprefix the prefix for the session key */
protected string $sessionkeyprefix = 'session_';
/** @var string $userkeyprefix the prefix for the user key */
protected string $userkeyprefix = 'user_';
/** @var int $acquiretimeout how long to wait for session lock in seconds */
protected $acquiretimeout = 120;
/** @var int $acquirewarn how long before warning when waiting for a lock in seconds */
@ -87,14 +83,18 @@ class redis extends handler implements SessionHandlerInterface {
/** @var string $lasthash hash of the session data content */
protected $lasthash = null;
/**
* @var int $lockexpire how long to wait in seconds before expiring the lock automatically
* so that other requests may continue execution, ignored if PECL redis is below version 2.2.0.
*/
protected $lockexpire;
/** @var int $gcbatchsize The number of redis keys that will be processed each time the garbage collector is executed. */
protected int $gcbatchsize = 100;
/** @var Redis|RedisCluster Connection */
protected $connection = null;
/**
* How long to wait in seconds before expiring the lock automatically so that other requests may continue execution.
*
* @var int $lockexpire
*/
protected int $lockexpire;
/** @var \Redis|\RedisCluster|null Connection */
protected \Redis|\RedisCluster|null $connection = null;
/** @var array $locks List of currently held locks by this page. */
protected $locks = array();
@ -108,6 +108,12 @@ class redis extends handler implements SessionHandlerInterface {
/** @var int Maximum number of retries for cache store operations. */
const MAX_RETRIES = 5;
/** @var int $firstaccesstimeout The initial timeout (seconds) for the first browser access without login. */
protected int $firstaccesstimeout = 180;
/** @var clock A clock instance */
protected clock $clock;
/**
* Create new instance of handler.
*/
@ -157,12 +163,6 @@ class redis extends handler implements SessionHandlerInterface {
$this->serializer = \Redis::SERIALIZER_IGBINARY; // Set igbinary serializer if phpredis supports it.
}
// The following configures the session lifetime in redis to allow some
// wriggle room in the user noticing they've been booted off and
// letting them log back in before they lose their session entirely.
$updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency;
$this->timeout = $CFG->sessiontimeout + $updatefreq + MINSECS;
// This sets the Redis session lock expiry time to whatever is lower, either
// the PHP execution time `max_execution_time`, if the value was defined in
// the `php.ini` or the globally configured `sessiontimeout`. Setting it to
@ -187,37 +187,33 @@ class redis extends handler implements SessionHandlerInterface {
if (isset($CFG->session_redis_compressor)) {
$this->compressor = $CFG->session_redis_compressor;
}
$this->clock = di::get(clock::class);
}
/**
* Start the session.
*
* @return bool success
*/
public function start() {
$result = parent::start();
return $result;
}
/**
* Init session handler.
*/
public function init() {
#[\Override]
public function init(): bool {
if (!extension_loaded('redis')) {
throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension is not loaded');
}
if (empty($this->host)) {
throw new exception('sessionhandlerproblem', 'error', '', null,
'$CFG->session_redis_host must be specified in config.php');
throw new exception(
'sessionhandlerproblem',
'error',
'',
null,
'$CFG->session_redis_host must be specified in config.php',
);
}
// The session handler requires a version of PHP Redis extension with support for SET command options (at least 2.2.4).
$version = phpversion('Redis');
if (!$version || version_compare($version, self::REDIS_EXTENSION_MIN_VERSION) <= 0) {
throw new exception('sessionhandlerproblem', 'error', '', null,
'redis extension version must be at least ' . self::REDIS_EXTENSION_MIN_VERSION);
if (!$version || version_compare($version, self::REDIS_MIN_EXTENSION_VERSION) <= 0) {
throw new exception(
errorcode: 'sessionhandlerproblem',
module: 'error',
debuginfo: sprintf('redis extension version must be at least %s', self::REDIS_MIN_EXTENSION_VERSION),
);
}
$result = session_set_save_handler($this);
@ -275,12 +271,26 @@ class redis extends handler implements SessionHandlerInterface {
try {
// Create a $redis object of a RedisCluster or Redis class.
if ($this->clustermode) {
$this->connection = new \RedisCluster(null, $trimmedservers, 1, 1, true,
$this->auth, !empty($opts) ? $opts : null);
$this->connection = new \RedisCluster(
name: null,
seeds: $trimmedservers,
timeout: 1,
readTimeout: 1,
persistent: true,
auth: $this->auth,
context: !empty($opts) ? $opts : null,
);
} else {
$delay = rand(100, 500);
$this->connection = new \Redis();
$this->connection->connect($server, $port, 1, null, $delay, 1, $opts);
$this->connection->connect(
host: $server,
port: $port,
timeout: 1,
retry_interval: $delay,
read_timeout: 1,
context: $opts,
);
if ($this->auth !== '' && !$this->connection->auth($this->auth)) {
throw new $exceptionclass('Unable to authenticate.');
}
@ -295,12 +305,25 @@ class redis extends handler implements SessionHandlerInterface {
throw new $exceptionclass('Unable to set the Redis Prefix option.');
}
}
if ($this->sslopts && !$this->connection->ping('Ping')) {
// In case of a TLS connection,
// if phpredis client does not communicate immediately with the server the connection hangs.
// See https://github.com/phpredis/phpredis/issues/2332.
throw new $exceptionclass("Ping failed");
$info = $this->connection->info('server');
if (!$info) {
throw new $exceptionclass("Failed to fetch server information");
}
// Check the server version.
// Note: In case of a TLS connection,
// if phpredis client does not communicate immediately with the server the connection hangs.
// See https://github.com/phpredis/phpredis/issues/2332.
// This version check satisfies that requirement.
$version = $info['redis_version'];
if (!$version || version_compare($version, static::REDIS_MIN_SERVER_VERSION) <= 0) {
throw new $exceptionclass(sprintf(
"Version %s is not supported. The minimum version required is %s.",
$version,
static::REDIS_MIN_SERVER_VERSION,
));
}
if ($this->database !== 0) {
if (!$this->connection->select($this->database)) {
throw new $exceptionclass('Unable to select the Redis database ' . $this->database . '.');
@ -309,9 +332,9 @@ class redis extends handler implements SessionHandlerInterface {
// The session handler requires a version of Redis server with support for SET command options (at least 2.6.12).
$serverversion = $this->connection->info('server')['redis_version'];
if (version_compare($serverversion, self::REDIS_SERVER_MIN_VERSION) <= 0) {
if (version_compare($serverversion, self::REDIS_MIN_SERVER_VERSION) <= 0) {
throw new exception('sessionhandlerproblem', 'error', '', null,
'redis server version must be at least ' . self::REDIS_SERVER_MIN_VERSION);
'redis server version must be at least ' . self::REDIS_MIN_SERVER_VERSION);
}
return true;
} catch (RedisException | RedisClusterException $e) {
@ -334,6 +357,7 @@ class redis extends handler implements SessionHandlerInterface {
if (!$result) {
throw new exception('redissessionhandlerproblem', 'error');
}
return false;
}
/**
@ -356,7 +380,7 @@ class redis extends handler implements SessionHandlerInterface {
$this->lasthash = null;
try {
foreach ($this->locks as $id => $expirytime) {
if ($expirytime > $this->time()) {
if ($expirytime > $this->clock->time()) {
$this->unlock_session($id);
}
unset($this->locks[$id]);
@ -373,29 +397,45 @@ class redis extends handler implements SessionHandlerInterface {
* Read the session data from storage
*
* @param string $id The session id to read from storage.
* @return string The session data for PHP to process.
* @return string|false The session data for PHP to process or false.
*
* @throws RedisException when we are unable to talk to the Redis server.
*/
public function read(string $id): string|false {
try {
if ($this->requires_write_lock()) {
$this->lock_session($id);
$this->lock_session($this->sessionkeyprefix . $id);
}
$sessiondata = $this->uncompress($this->connection->get($id));
$keys = $this->connection->hmget($this->sessionkeyprefix . $id, ['userid', 'sessdata']);
$userid = $keys['userid'];
$sessiondata = $this->uncompress($keys['sessdata']);
if ($sessiondata === false) {
if ($this->requires_write_lock()) {
$this->unlock_session($id);
$this->unlock_session($this->sessionkeyprefix . $id);
}
$this->lasthash = sha1('');
return '';
}
$this->connection->expire($id, $this->timeout);
// Do not update expiry if non-login user (0). This would affect the first access timeout.
if ($userid != 0) {
$maxlifetime = $this->get_maxlifetime($userid);
$this->connection->expire($this->sessionkeyprefix . $id, $maxlifetime);
$this->connection->expire($this->userkeyprefix . $userid, $maxlifetime);
}
} catch (RedisException | RedisClusterException $e) {
error_log('Failed talking to redis: '.$e->getMessage());
throw $e;
}
// Update last hash.
if ($sessiondata === null) {
// As of PHP 8.1 we can't pass null to base64_encode.
$sessiondata = '';
}
$this->lasthash = sha1(base64_encode($sessiondata));
return $sessiondata;
}
@ -406,7 +446,7 @@ class redis extends handler implements SessionHandlerInterface {
* @param mixed $value
* @return string
*/
private function compress($value) {
private function compress($value): string {
switch ($this->compressor) {
case self::COMPRESSION_NONE:
return $value;
@ -455,7 +495,6 @@ class redis extends handler implements SessionHandlerInterface {
* @return bool true on write success, false on failure
*/
public function write(string $id, string $data): bool {
$hash = sha1(base64_encode($data));
// If the content has not changed don't bother writing.
@ -475,8 +514,16 @@ class redis extends handler implements SessionHandlerInterface {
// address that in the future.
try {
$data = $this->compress($data);
$this->connection->hset($this->sessionkeyprefix . $id, 'sessdata', $data);
$keys = $this->connection->hmget($this->sessionkeyprefix . $id, ['userid', 'timecreated', 'timemodified']);
$userid = $keys['userid'];
$this->connection->setex($id, $this->timeout, $data);
// Don't update expiry if still first access.
if ($keys['timecreated'] != $keys['timemodified']) {
$maxlifetime = $this->get_maxlifetime($userid);
$this->connection->expire($this->sessionkeyprefix . $id, $maxlifetime);
$this->connection->expire($this->userkeyprefix . $userid, $maxlifetime);
}
} catch (RedisException | RedisClusterException $e) {
error_log('Failed talking to redis: '.$e->getMessage());
return false;
@ -484,16 +531,124 @@ class redis extends handler implements SessionHandlerInterface {
return true;
}
/**
* Handle destroying a session.
*
* @param string $id the session id to destroy.
* @return bool true if the session was deleted, false otherwise.
*/
#[\Override]
public function get_session_by_sid(string $sid): \stdClass {
$keys = ["id", "state", "sid", "userid", "sessdata", "timecreated", "timemodified", "firstip", "lastip"];
$sessiondata = $this->connection->hmget($this->sessionkeyprefix . $sid, $keys);
return (object)$sessiondata;
}
#[\Override]
public function add_session(int $userid): \stdClass {
$timestamp = $this->clock->time();
$sid = session_id();
$maxlifetime = $this->get_maxlifetime($userid, true);
$sessiondata = [
'id' => $sid,
'state' => '0',
'sid' => $sid,
'userid' => $userid,
'sessdata' => null,
'timecreated' => $timestamp,
'timemodified' => $timestamp,
'firstip' => getremoteaddr(),
'lastip' => getremoteaddr(),
];
$userhashkey = $this->userkeyprefix . $userid;
$this->connection->hSet($userhashkey, $sid, $timestamp);
$this->connection->expire($userhashkey, $maxlifetime);
$sessionhashkey = $this->sessionkeyprefix . $sid;
$this->connection->hmSet($sessionhashkey, $sessiondata);
$this->connection->expire($sessionhashkey, $maxlifetime);
return (object)$sessiondata;
}
#[\Override]
public function get_sessions_by_userid(int $userid): array {
$this->init_redis_if_required();
$userhashkey = $this->userkeyprefix . $userid;
$sessions = $this->connection->hGetAll($userhashkey);
$records = [];
foreach (array_keys($sessions) as $session) {
$item = $this->connection->hGetAll($this->sessionkeyprefix . $session);
if (!empty($item)) {
$records[] = (object) $item;
}
}
return $records;
}
#[\Override]
public function update_session(\stdClass $record): bool {
if (!isset($record->sid) && isset($record->id)) {
$record->sid = $record->id;
}
// If record does not have userid set, we need to get it from the session.
if (!isset($record->userid)) {
$session = $this->get_session_by_sid($record->sid);
$record->userid = $session->userid;
}
$sessionhashkey = $this->sessionkeyprefix . $record->sid;
$userhashkey = $this->userkeyprefix . $record->userid;
$recordata = (array) $record;
unset($recordata['sid']);
$this->connection->hmSet($sessionhashkey, $recordata);
// Update the expiry time.
$maxlifetime = $this->get_maxlifetime($record->userid);
$this->connection->expire($sessionhashkey, $maxlifetime);
$this->connection->expire($userhashkey, $maxlifetime);
return true;
}
#[\Override]
public function get_all_sessions(): \Iterator {
$sessions = [];
$iterator = null;
while (false !== ($keys = $this->connection->scan($iterator, '*' . $this->sessionkeyprefix . '*'))) {
foreach ($keys as $key) {
$sessions[] = $key;
}
}
return new \ArrayIterator($sessions);
}
#[\Override]
public function destroy_all(): bool {
$this->init_redis_if_required();
$sessions = $this->get_all_sessions();
foreach ($sessions as $session) {
// Remove the prefixes from the session id, as destroy expects the raw session id.
if (str_starts_with($session, $this->prefix . $this->sessionkeyprefix)) {
$session = substr($session, strlen($this->prefix . $this->sessionkeyprefix));
}
$this->destroy($session);
}
return true;
}
#[\Override]
public function destroy(string $id): bool {
$this->init_redis_if_required();
$this->lasthash = null;
try {
$this->connection->del($id);
$sessionhashkey = $this->sessionkeyprefix . $id;
$userid = $this->connection->hget($sessionhashkey, "userid");
$userhashkey = $this->userkeyprefix . $userid;
$this->connection->hDel($userhashkey, $id);
$this->connection->unlink($sessionhashkey);
$this->unlock_session($id);
} catch (RedisException | RedisClusterException $e) {
error_log('Failed talking to redis: '.$e->getMessage());
@ -503,15 +658,53 @@ class redis extends handler implements SessionHandlerInterface {
return true;
}
// phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameUnderscore
#[\Override]
public function gc(int $max_lifetime = 0): int|false {
return 0;
}
// phpcs:enable
/**
* Garbage collect sessions. We don't we any as Redis does it for us.
* Get session maximum lifetime in seconds.
*
* @param integer $max_lifetime All sessions older than this should be removed.
* @return bool true, as Redis handles expiry for us.
* @param int|null $userid The user id to calculate the max lifetime for.
* @param bool $firstbrowseraccess This indicates that this is calculating the expiry when the key is first added.
* The first access made by the browser has a shorter timeout to reduce abandoned sessions.
* @return float|int
*/
// phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameUnderscore
public function gc(int $max_lifetime): int|false {
return false;
private function get_maxlifetime(?int $userid = null, bool $firstbrowseraccess = false): float|int {
global $CFG;
// Guest user.
if ($userid == $CFG->siteguest) {
return $CFG->sessiontimeout * 5;
}
// All other users.
if ($userid == 0 && $firstbrowseraccess) {
$maxlifetime = $this->firstaccesstimeout;
} else {
// As per MDL-56823 - The following configures the session lifetime in redis to allow some
// wriggle room in the user noticing they've been booted off and
// letting them log back in before they lose their session entirely.
$updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency;
$maxlifetime = (int) $CFG->sessiontimeout + $updatefreq + MINSECS;
}
return $maxlifetime;
}
/**
* Connection will be null if these methods are called from cli or where NO_MOODLE_COOKIES is used.
* We need to check for this and initialize the connection if required.
*
* @return void
*/
private function init_redis_if_required(): void {
if (is_null($this->connection)) {
$this->init();
}
}
/**
@ -521,7 +714,7 @@ class redis extends handler implements SessionHandlerInterface {
*/
protected function unlock_session($id) {
if (isset($this->locks[$id])) {
$this->connection->del($id.".lock");
$this->connection->unlink("{$id}.lock");
unset($this->locks[$id]);
}
}
@ -534,25 +727,30 @@ class redis extends handler implements SessionHandlerInterface {
* @throws exception When we are unable to obtain a session lock.
*/
protected function lock_session($id) {
$lockkey = $id.".lock";
$lockkey = "{$id}.lock";
$haslock = isset($this->locks[$id]) && $this->time() < $this->locks[$id];
$startlocktime = $this->time();
$haslock = isset($this->locks[$id]) && $this->clock->time() < $this->locks[$id];
if ($haslock) {
return true;
}
/* To be able to ensure sessions don't write out of order we must obtain an exclusive lock
* on the session for the entire time it is open. If another AJAX call, or page is using
* the session then we just wait until it finishes before we can open the session.
*/
$startlocktime = $this->clock->time();
// To be able to ensure sessions don't write out of order we must obtain an exclusive lock
// on the session for the entire time it is open. If another AJAX call, or page is using
// the session then we just wait until it finishes before we can open the session.
// Store the current host, process id and the request URI so it's easy to track who has the lock.
$hostname = gethostname();
if ($hostname === false) {
$hostname = 'UNKNOWN HOST';
}
$pid = getmypid();
if ($pid === false) {
$pid = 'UNKNOWN';
}
$uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown uri';
$whoami = "[pid {$pid}] {$hostname}:$uri";
@ -563,38 +761,43 @@ class redis extends handler implements SessionHandlerInterface {
$haslock = $this->connection->set($lockkey, $whoami, ['nx', 'ex' => $this->lockexpire]);
if ($haslock) {
$this->locks[$id] = $this->time() + $this->lockexpire;
$this->locks[$id] = $this->clock->time() + $this->lockexpire;
return true;
}
if (!empty($this->acquirewarn) && !$haswarned && $this->time() > $startlocktime + $this->acquirewarn) {
if (!empty($this->acquirewarn) && !$haswarned && $this->clock->time() > $startlocktime + $this->acquirewarn) {
// This is a warning to better inform users.
$whohaslock = $this->connection->get($lockkey);
// phpcs:ignore
error_log("Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " .
"It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.");
error_log(
"Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " .
"It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.",
);
$haswarned = true;
}
if ($this->time() > $startlocktime + $this->acquiretimeout) {
if ($this->clock->time() > $startlocktime + $this->acquiretimeout) {
// This is a fatal error, better inform users.
// It should not happen very often - all pages that need long time to execute
// should close session immediately after access control checks.
$whohaslock = $this->connection->get($lockkey);
// phpcs:ignore
error_log("Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " .
"It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.");
error_log(
"Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " .
"It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.",
);
$acquiretimeout = format_time($this->acquiretimeout);
$lockexpire = format_time($this->lockexpire);
$a = (object)[
'id' => substr($id, 0, 10),
'acquiretimeout' => $acquiretimeout,
'whohaslock' => $whohaslock,
'lockexpire' => $lockexpire];
'lockexpire' => $lockexpire,
];
throw new exception("sessioncannotobtainlock", 'error', '', $a);
}
if ($this->time() < $startlocktime + 5) {
if ($this->clock->time() < $startlocktime + 5) {
// We want a random delay to stagger the polling load. Ideally
// this delay should be a fraction of the average response
// time. If it is too small we will poll too much and if it is
@ -610,63 +813,20 @@ class redis extends handler implements SessionHandlerInterface {
usleep($delay * 1000);
}
throw new coding_exception('Unable to lock session');
}
/**
* Return the current time.
*
* @return int the current time as a unixtimestamp.
*/
protected function time() {
return time();
}
/**
* Check the backend contains data for this session id.
*
* Note: this is intended to be called from manager::session_exists() only.
*
* @param string $sid
* @return bool true if session found.
*/
#[\Override]
public function session_exists($sid) {
if (!$this->connection) {
return false;
}
try {
return !empty($this->connection->exists($sid));
$sessionhashkey = $this->sessionkeyprefix . $sid;
return !empty($this->connection->exists($sessionhashkey));
} catch (RedisException | RedisClusterException $e) {
return false;
}
}
/**
* Kill all active sessions, the core sessions table is purged afterwards.
*/
public function kill_all_sessions() {
global $DB;
if (!$this->connection) {
return;
}
$rs = $DB->get_recordset('sessions', array(), 'id DESC', 'id, sid');
foreach ($rs as $record) {
$this->destroy($record->sid);
}
$rs->close();
}
/**
* Kill one session, the session record is removed afterwards.
*
* @param string $sid
*/
public function kill_session($sid) {
if (!$this->connection) {
return;
}
$this->destroy($sid);
}
}

View File

@ -2689,7 +2689,8 @@ function require_logout() {
'other' => array('sessionid' => $sid),
)
);
if ($session = $DB->get_record('sessions', array('sid'=>$sid))) {
$session = \core\session\manager::get_session_by_sid($sid);
if (isset($session->id)) {
$event->add_record_snapshot('sessions', $session);
}
@ -3675,7 +3676,7 @@ function delete_user(stdClass $user) {
}
// Force logout - may fail if file based sessions used, sorry.
\core\session\manager::kill_user_sessions($user->id);
\core\session\manager::destroy_user_sessions($user->id);
// Generate username from email address, or a fake email.
$delemail = !empty($user->email) ? $user->email : $user->username . '.' . $user->id . '@unknownemail.invalid';

View File

@ -0,0 +1,86 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\tests\session;
use core\clock;
use core\di;
use core\session\database;
/**
* Mock handler methods class.
*
* @package core
* @author Darren Cocco <moodle@darren.cocco.id.au>
* @author Trisha Milan <trishamilan@catalyst-au.net>
* @copyright 2022 Monash University (http://www.monash.edu)
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class mock_handler extends database {
#[\Override]
public function init(): bool {
// Nothing special to do in the mock.
return true;
}
#[\Override]
public function session_exists($sid): bool {
global $DB;
return $DB->record_exists('sessions', ['sid' => $sid]);
}
/**
* Insert a new session record to be used in unit tests.
*
* @param \stdClass $record
* @return int Inserted record id.
*/
public function add_test_session(\stdClass $record): int {
global $DB, $USER;
$data = new \stdClass();
$data->state = $record->state ?? 0;
$data->sid = $record->sid ?? session_id();
$data->sessdata = $record->sessdata ?? null;
$data->userid = $record->userid ?? $USER->id;
$data->timecreated = $record->timecreated ?? di::get(clock::class)->time();
$data->timemodified = $record->timemodified ?? di::get(clock::class)->time();
$data->firstip = $record->firstip ?? getremoteaddr();
$data->lastip = $record->lastip ?? getremoteaddr();
return $DB->insert_record('sessions', $data);
}
#[\Override]
public function get_all_sessions(): \Iterator {
global $DB;
$records = $DB->get_records('sessions');
return new \ArrayIterator($records);
}
/**
* Returns the number of all sessions stored.
*
* @return int
*/
public function count_sessions(): int {
global $DB;
return $DB->count_records('sessions');
}
}

View File

@ -14,7 +14,9 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
namespace core\session;
use core\tests\session\mock_handler;
/**
* Unit tests for session manager class.
@ -23,8 +25,18 @@ namespace core;
* @category test
* @copyright 2013 Petr Skoda {@link http://skodak.org}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core\session\manager
*/
class session_manager_test extends \advanced_testcase {
final class manager_test extends \advanced_testcase {
/** @var mock_handler $mockhandler Dedicated testing handler. */
protected mock_handler $mockhandler;
protected function setUp(): void {
parent::setUp();
$this->mockhandler = new mock_handler();
}
public function test_start(): void {
$this->resetAfterTest();
// Session must be started only once...
@ -185,23 +197,23 @@ class session_manager_test extends \advanced_testcase {
$record->sid = $sid;
$record->timecreated = time();
$record->timemodified = $record->timecreated;
$record->id = $DB->insert_record('sessions', $record);
$record->id = $this->mockhandler->add_test_session($record);
$this->assertTrue(\core\session\manager::session_exists($sid));
$record->timecreated = time() - $CFG->sessiontimeout - 100;
$record->timemodified = $record->timecreated + 10;
$DB->update_record('sessions', $record);
\core\session\manager::update_session($record);
$this->assertTrue(\core\session\manager::session_exists($sid));
$record->userid = $guest->id;
$DB->update_record('sessions', $record);
\core\session\manager::update_session($record);
$this->assertTrue(\core\session\manager::session_exists($sid));
$record->userid = $user->id;
$DB->update_record('sessions', $record);
\core\session\manager::update_session($record);
$this->assertFalse(\core\session\manager::session_exists($sid));
@ -211,7 +223,6 @@ class session_manager_test extends \advanced_testcase {
}
public function test_touch_session(): void {
global $DB;
$this->resetAfterTest();
$sid = md5('hokus');
@ -223,17 +234,23 @@ class session_manager_test extends \advanced_testcase {
$record->timecreated = time() - 60*60;
$record->timemodified = time() - 30;
$record->firstip = $record->lastip = '10.0.0.1';
$record->id = $DB->insert_record('sessions', $record);
$record->id = $this->mockhandler->add_test_session($record);
$now = time();
\core\session\manager::touch_session($sid);
$updated = $DB->get_field('sessions', 'timemodified', array('id'=>$record->id));
$session = \core\session\manager::get_session_by_sid($sid);
$this->assertGreaterThanOrEqual($now, $updated);
$this->assertLessThanOrEqual(time(), $updated);
$this->assertGreaterThanOrEqual($now, $session->timemodified);
$this->assertLessThanOrEqual(time(), $session->timemodified);
}
public function test_kill_session(): void {
/**
* Test destroy method.
*
* @return void
* @throws \dml_exception
*/
public function test_destroy(): void {
global $DB, $USER;
$this->resetAfterTest();
@ -249,23 +266,23 @@ class session_manager_test extends \advanced_testcase {
$record->timecreated = time() - 60*60;
$record->timemodified = time() - 30;
$record->firstip = $record->lastip = '10.0.0.1';
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->userid = 0;
$record->sid = md5('pokus');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$this->assertEquals(2, $DB->count_records('sessions'));
$this->assertEquals(2, $this->mockhandler->count_sessions());
\core\session\manager::kill_session($sid);
$this->assertEquals(1, $DB->count_records('sessions'));
$this->assertFalse($DB->record_exists('sessions', array('sid'=>$sid)));
\core\session\manager::destroy($sid);
$sessions = $this->mockhandler->get_all_sessions();
$this->assertEquals(1, count($sessions));
$this->assertFalse($this->contains_session(['sid' => $sid], $sessions));
$this->assertSame($userid, $USER->id);
}
public function test_kill_user_sessions(): void {
public function test_destroy_user_sessions(): void {
global $DB, $USER;
$this->resetAfterTest();
@ -281,40 +298,44 @@ class session_manager_test extends \advanced_testcase {
$record->timecreated = time() - 60*60;
$record->timemodified = time() - 30;
$record->firstip = $record->lastip = '10.0.0.1';
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('hokus2');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->userid = 0;
$record->sid = md5('pokus');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$this->assertEquals(3, $DB->count_records('sessions'));
\core\session\manager::kill_user_sessions($userid);
\core\session\manager::destroy_user_sessions($userid);
$this->assertEquals(1, $DB->count_records('sessions'));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $userid)));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertEquals(1, count($sessions));
$this->assertFalse($this->contains_session(['userid' => $userid], $sessions));
$record->userid = $userid;
$record->sid = md5('pokus3');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->userid = $userid;
$record->sid = md5('pokus4');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->userid = $userid;
$record->sid = md5('pokus5');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$this->assertEquals(3, $DB->count_records('sessions', array('userid' => $userid)));
$sessions = \core\session\manager::get_sessions_by_userid($userid);
$this->assertCount(3, $sessions);
\core\session\manager::kill_user_sessions($userid, md5('pokus5'));
\core\session\manager::destroy_user_sessions($userid, md5('pokus5'));
$this->assertEquals(1, $DB->count_records('sessions', array('userid' => $userid)));
$this->assertEquals(1, $DB->count_records('sessions', array('userid' => $userid, 'sid' => md5('pokus5'))));
$sessions = \core\session\manager::get_sessions_by_userid($userid);
$session = reset($sessions);
$this->assertCount(1, $sessions);
$this->assertEquals(md5('pokus5'), $session->sid);
}
public function test_apply_concurrent_login_limit(): void {
@ -334,41 +355,41 @@ class session_manager_test extends \advanced_testcase {
$record->sid = md5('hokus1');
$record->timecreated = 20;
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('hokus2');
$record->timecreated = 10;
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('hokus3');
$record->timecreated = 30;
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->userid = $user2->id;
$record->sid = md5('pokus1');
$record->timecreated = 20;
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('pokus2');
$record->timecreated = 10;
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('pokus3');
$record->timecreated = 30;
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->timecreated = 10;
$record->userid = $guest->id;
$record->sid = md5('g1');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('g2');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('g3');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->userid = 0;
$record->sid = md5('nl1');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('nl2');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('nl3');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
set_config('limitconcurrentlogins', 0);
$this->assertCount(12, $DB->get_records('sessions'));
@ -390,57 +411,103 @@ class session_manager_test extends \advanced_testcase {
set_config('limitconcurrentlogins', 2);
\core\session\manager::apply_concurrent_login_limit($user1->id);
$this->assertCount(11, $DB->get_records('sessions'));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 20)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 30)));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 10)));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertCount(11, $sessions);
$this->assertTrue($this->contains_session(['userid' => $user1->id, 'timecreated' => 20], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user1->id, 'timecreated' => 30], $sessions));
$this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 10], $sessions));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 20)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 30)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 10)));
$this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 20], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 30], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 10], $sessions));
set_config('limitconcurrentlogins', 2);
\core\session\manager::apply_concurrent_login_limit($user2->id, md5('pokus2'));
$this->assertCount(10, $DB->get_records('sessions'));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 20)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 30)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 10)));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertCount(10, $sessions);
$this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 20], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 30], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 10], $sessions));
\core\session\manager::apply_concurrent_login_limit($guest->id);
\core\session\manager::apply_concurrent_login_limit(0);
$this->assertCount(10, $DB->get_records('sessions'));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertCount(10, $sessions);
set_config('limitconcurrentlogins', 1);
\core\session\manager::apply_concurrent_login_limit($user1->id, md5('grrr'));
$this->assertCount(9, $DB->get_records('sessions'));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 20)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 30)));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 10)));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertCount(9, $sessions);
$this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 20], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user1->id, 'timecreated' => 30], $sessions));
$this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 10], $sessions));
\core\session\manager::apply_concurrent_login_limit($user1->id);
$this->assertCount(9, $DB->get_records('sessions'));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 20)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 30)));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 10)));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertCount(9, $sessions);
$this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 20], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user1->id, 'timecreated' => 30], $sessions));
$this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 10], $sessions));
\core\session\manager::apply_concurrent_login_limit($user2->id, md5('pokus2'));
$this->assertCount(8, $DB->get_records('sessions'));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 20)));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 30)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 10)));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertCount(8, $sessions);
$this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 20], $sessions));
$this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 30], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 10], $sessions));
\core\session\manager::apply_concurrent_login_limit($user2->id);
$this->assertCount(8, $DB->get_records('sessions'));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 20)));
$this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 30)));
$this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 10)));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertCount(8, $sessions);
$this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 20], $sessions));
$this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 30], $sessions));
$this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 10], $sessions));
\core\session\manager::apply_concurrent_login_limit($guest->id);
\core\session\manager::apply_concurrent_login_limit(0);
$this->assertCount(8, $DB->get_records('sessions'));
$sessions = $this->mockhandler->get_all_sessions();
$this->assertCount(8, $sessions);
}
public function test_kill_all_sessions(): void {
/**
* Helper method to check if the sessions array contains a session with the given conditions.
*
* @param array $conditions Conditions to match.
* @param null|\Iterator $sessions Sessions to check.
* @return bool
*/
protected function contains_session(array $conditions, ?\Iterator $sessions = null): bool {
foreach ($sessions as $session) {
if ($this->matches_session($conditions, $session)) {
return true;
}
}
return false;
}
/**
* Helper method to check if the session matches the given conditions.
*
* @param array $conditions Conditions to match.
* @param \stdClass $session Session to check.
* @return bool
*/
protected function matches_session(array $conditions, \stdClass $session): bool {
foreach ($conditions as $key => $value) {
if ($session->$key != $value) {
return false;
}
}
return true;
}
/**
* Test destroy_all method.
*
* @return void
* @throws \dml_exception
*/
public function test_destroy_all(): void {
global $DB, $USER;
$this->resetAfterTest();
@ -456,25 +523,25 @@ class session_manager_test extends \advanced_testcase {
$record->timecreated = time() - 60*60;
$record->timemodified = time() - 30;
$record->firstip = $record->lastip = '10.0.0.1';
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->sid = md5('hokus2');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$record->userid = 0;
$record->sid = md5('pokus');
$DB->insert_record('sessions', $record);
$this->mockhandler->add_test_session($record);
$this->assertEquals(3, $DB->count_records('sessions'));
\core\session\manager::kill_all_sessions();
\core\session\manager::destroy_all();
$this->assertEquals(0, $DB->count_records('sessions'));
$this->assertSame(0, $USER->id);
}
public function test_gc(): void {
global $CFG, $DB, $USER;
global $CFG, $USER;
$this->resetAfterTest();
$this->setAdminUser();
@ -483,6 +550,8 @@ class session_manager_test extends \advanced_testcase {
$guestid = $USER->id;
$this->setUser(0);
// Set sessions timeout to 600 (10 minutes) seconds.
// We will test if sessions not modified for 600 seconds are removed.
$CFG->sessiontimeout = 60*10;
$record = new \stdClass();
@ -493,53 +562,53 @@ class session_manager_test extends \advanced_testcase {
$record->timecreated = time() - 60*60;
$record->timemodified = time() - 30;
$record->firstip = $record->lastip = '10.0.0.1';
$r1 = $DB->insert_record('sessions', $record);
$r1 = $this->mockhandler->add_test_session($record);
$record->sid = md5('hokus2');
$record->userid = $adminid;
$record->timecreated = time() - 60*60;
$record->timemodified = time() - 60*20;
$r2 = $DB->insert_record('sessions', $record);
$r2 = $this->mockhandler->add_test_session($record);
$record->sid = md5('hokus3');
$record->userid = $guestid;
$record->timecreated = time() - 60*60*60;
$record->timemodified = time() - 60*20;
$r3 = $DB->insert_record('sessions', $record);
$r3 = $this->mockhandler->add_test_session($record);
$record->sid = md5('hokus4');
$record->userid = $guestid;
$record->timecreated = time() - 60*60*60;
$record->timemodified = time() - 60*10*5 - 60;
$r4 = $DB->insert_record('sessions', $record);
$r4 = $this->mockhandler->add_test_session($record);
$record->sid = md5('hokus5');
$record->userid = 0;
$record->timecreated = time() - 60*5;
$record->timemodified = time() - 60*5;
$r5 = $DB->insert_record('sessions', $record);
$r5 = $this->mockhandler->add_test_session($record);
$record->sid = md5('hokus6');
$record->userid = 0;
$record->timecreated = time() - 60*60;
$record->timemodified = time() - 60*10 -10;
$r6 = $DB->insert_record('sessions', $record);
$r6 = $this->mockhandler->add_test_session($record);
$record->sid = md5('hokus7');
$record->userid = 0;
$record->timecreated = time() - 60*60;
$record->timemodified = time() - 60*9;
$r7 = $DB->insert_record('sessions', $record);
$r7 = $this->mockhandler->add_test_session($record);
\core\session\manager::gc();
$this->assertTrue($DB->record_exists('sessions', array('id'=>$r1)));
$this->assertFalse($DB->record_exists('sessions', array('id'=>$r2)));
$this->assertTrue($DB->record_exists('sessions', array('id'=>$r3)));
$this->assertFalse($DB->record_exists('sessions', array('id'=>$r4)));
$this->assertFalse($DB->record_exists('sessions', array('id'=>$r5)));
$this->assertFalse($DB->record_exists('sessions', array('id'=>$r6)));
$this->assertTrue($DB->record_exists('sessions', array('id'=>$r7)));
\core\session\manager::gc($CFG->sessiontimeout);
$sessions = $this->mockhandler->get_all_sessions();
$this->assertTrue($this->contains_session(['id' => $r1], $sessions));
$this->assertFalse($this->contains_session(['id' => $r2], $sessions));
$this->assertTrue($this->contains_session(['id' => $r3], $sessions));
$this->assertFalse($this->contains_session(['id' => $r4], $sessions));
$this->assertFalse($this->contains_session(['id' => $r5], $sessions));
$this->assertFalse($this->contains_session(['id' => $r6], $sessions));
$this->assertTrue($this->contains_session(['id' => $r7], $sessions));
}
/**
@ -648,7 +717,7 @@ class session_manager_test extends \advanced_testcase {
*
* @return array
*/
public function pages_sessionlocks() {
public function pages_sessionlocks(): array {
return [
[
'url' => '/good.php',
@ -738,7 +807,7 @@ class session_manager_test extends \advanced_testcase {
*
* @return array
*/
public function sessionlock_history() {
public function sessionlock_history(): array {
return [
[
'url' => '/good.php',
@ -849,7 +918,7 @@ class session_manager_test extends \advanced_testcase {
*
* @return array
*/
public function array_session_diff_provider() {
public static function array_session_diff_provider(): array {
// Create an instance of this object so the comparison object's identities are the same.
// Used in one of the tests below.
$compareobjectb = (object) ['array' => 'b'];
@ -920,4 +989,44 @@ class session_manager_test extends \advanced_testcase {
$result = $method->invokeArgs(null, [$a, $b]);
$this->assertSame($expected, $result);
}
/**
* Test destroy by auth plugin method.
*/
public function test_destroy_by_auth_plugin(): void {
$this->resetAfterTest();
global $DB;
// Create test users.
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user(['auth' => 'db']);
// Create sessions for the users.
$user1sid = md5('hokus');
$record = new \stdClass();
$record->state = 0;
$record->sid = $user1sid;
$record->sessdata = null;
$record->userid = $user1->id;
$record->timecreated = time() - 60 * 60;
$record->timemodified = time() - 30;
$record->firstip = $record->lastip = '10.0.0.1';
$this->mockhandler->add_test_session($record);
$record->sid = md5('pokus');
$record->userid = $user2->id;
$this->mockhandler->add_test_session($record);
// Check sessions.
$sessions = $this->mockhandler->get_all_sessions();
$this->assertEquals(2, count($sessions));
// Destroy the session for the user with manual auth plugin.
\core\session\manager::destroy_by_auth_plugin('manual');
// Check that the session for the user with manual auth plugin is destroyed.
$sessions = $this->mockhandler->get_all_sessions();
$this->assertEquals(1, count($sessions));
$this->assertFalse($this->contains_session(['sid' => $user1sid], $sessions));
}
}

View File

@ -14,8 +14,9 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
namespace core\session;
use core\tests\session\mock_handler;
use Redis;
use RedisException;
@ -34,21 +35,21 @@ use RedisException;
* @copyright 2016 Russell Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @runClassInSeparateProcess
* @covers \core\session\redis
*/
class session_redis_test extends \advanced_testcase {
/** @var $keyprefix This key prefix used when testing Redis */
protected $keyprefix = null;
/** @var $redis The current testing redis connection */
protected $redis = null;
final class redis_test extends \advanced_testcase {
/** @var string $keyprefix This key prefix used when testing Redis */
protected string $keyprefix = '';
/** @var ?Redis $redis The current testing redis connection */
protected ?Redis $redis = null;
/** @var bool $encrypted Is the current testing redis connection encrypted*/
protected $encrypted = false;
protected bool $encrypted = false;
/** @var int $acquiretimeout how long we wait for session lock in seconds when testing Redis */
protected $acquiretimeout = 1;
protected int $acquiretimeout = 1;
/** @var int $lockexpire how long to wait in seconds before expiring the lock when testing Redis */
protected $lockexpire = 70;
protected int $lockexpire = 70;
#[\Override]
public function setUp(): void {
global $CFG;
parent::setUp();
@ -62,8 +63,8 @@ class session_redis_test extends \advanced_testcase {
$version = phpversion('Redis');
if (!$version) {
$this->markTestSkipped('Redis extension version missing');
} else if (version_compare($version, \core\session\redis::REDIS_EXTENSION_MIN_VERSION) <= 0) {
$this->markTestSkipped('Redis extension version must be at least ' . \core\session\redis::REDIS_EXTENSION_MIN_VERSION .
} else if (version_compare($version, \core\session\redis::REDIS_MIN_EXTENSION_VERSION) <= 0) {
$this->markTestSkipped('Redis extension version must be at least ' . \core\session\redis::REDIS_MIN_EXTENSION_VERSION .
': now running "' . $version . '"');
}
@ -135,7 +136,7 @@ class session_redis_test extends \advanced_testcase {
$this->assertSame('DATA', $sess->read('sess1'));
$this->assertTrue($sess->write('sess1', 'DATA-new'));
$this->assertTrue($sess->close());
$this->assertSessionNoLocks();
$this->assert_session_no_locks();
}
public function test_compression_read_and_write_works(): void {
@ -187,16 +188,16 @@ class session_redis_test extends \advanced_testcase {
$sessblocked->read('sess1');
$this->fail('Session lock must fail to be obtained.');
} catch (\core\session\exception $e) {
$this->assertStringContainsString("Unable to obtain lock for session id sess1", $e->getMessage());
$this->assertStringContainsString("Unable to obtain lock for session id session_se", $e->getMessage());
$this->assertStringContainsString('within 1 sec.', $e->getMessage());
$this->assertStringContainsString('session lock timeout (1 min 10 secs) ', $e->getMessage());
$this->assertStringContainsString('Cannot obtain session lock for sid: sess1', file_get_contents($errorlog));
$this->assertStringContainsString('Cannot obtain session lock for sid: session_sess1', file_get_contents($errorlog));
}
$this->assertTrue($sessblocked->close());
$this->assertTrue($sess->write('sess1', 'DATA-new'));
$this->assertTrue($sess->close());
$this->assertSessionNoLocks();
$this->assert_session_no_locks();
}
public function test_session_is_destroyed_when_it_does_not_exist(): void {
@ -205,7 +206,7 @@ class session_redis_test extends \advanced_testcase {
$sess->set_requires_write_lock(true);
$this->assertTrue($sess->open('Not used', 'Not used'));
$this->assertTrue($sess->destroy('sess-destroy'));
$this->assertSessionNoLocks();
$this->assert_session_no_locks();
}
public function test_session_is_destroyed_when_we_have_it_open(): void {
@ -216,7 +217,7 @@ class session_redis_test extends \advanced_testcase {
$this->assertSame('', $sess->read('sess-destroy'));
$this->assertTrue($sess->destroy('sess-destroy'));
$this->assertTrue($sess->close());
$this->assertSessionNoLocks();
$this->assert_session_no_locks();
}
public function test_multiple_sessions_do_not_interfere_with_each_other(): void {
@ -260,7 +261,7 @@ class session_redis_test extends \advanced_testcase {
$this->assertTrue($sess2->close());
// Read the session again to ensure locking did what it should.
$this->assertSessionNoLocks();
$this->assert_session_no_locks();
}
public function test_multiple_sessions_work_with_a_single_instance(): void {
@ -279,7 +280,7 @@ class session_redis_test extends \advanced_testcase {
$this->assertTrue($sess->destroy('sess2'));
$this->assertTrue($sess->close());
$this->assertSessionNoLocks();
$this->assert_session_no_locks();
$this->assertTrue($sess->close());
}
@ -299,12 +300,14 @@ class session_redis_test extends \advanced_testcase {
$this->assertFalse($sess->session_exists('sess1'), 'Session should be destroyed.');
}
public function test_kill_sessions_removes_the_session_from_redis(): void {
public function test_destroy_removes_the_session_from_redis(): void {
global $DB;
$sess = new \core\session\redis();
$sess->init();
$mockhandler = new mock_handler();
$this->assertTrue($sess->open('Not used', 'Not used'));
$this->assertTrue($sess->write('sess1', 'DATA'));
$this->assertTrue($sess->write('sess2', 'DATA'));
@ -316,22 +319,27 @@ class session_redis_test extends \advanced_testcase {
$sessiondata->timemodified = time();
$sessiondata->sid = 'sess1';
$DB->insert_record('sessions', $sessiondata);
$mockhandler->add_test_session($sessiondata);
$sessiondata->sid = 'sess2';
$DB->insert_record('sessions', $sessiondata);
$mockhandler->add_test_session($sessiondata);
$sessiondata->sid = 'sess3';
$DB->insert_record('sessions', $sessiondata);
$mockhandler->add_test_session($sessiondata);
$this->assertNotEquals('', $sess->read('sess1'));
$sess->kill_session('sess1');
$sess->destroy('sess1');
$this->assertEquals('', $sess->read('sess1'));
$this->assertEmpty($this->redis->keys($this->keyprefix.'sess1.lock'));
$sess->kill_all_sessions();
$sess->destroy_all();
$this->assertEquals(3, $DB->count_records('sessions'), 'Moodle handles session database, plugin must not change it.');
$this->assertSessionNoLocks();
$mockhandler = new mock_handler();
$this->assertEquals(
3,
$mockhandler->count_sessions(),
'Moodle handles session database, plugin must not change it.',
);
$this->assert_session_no_locks();
$this->assertEmpty($this->redis->keys($this->keyprefix.'*'), 'There should be no session data left.');
}
@ -360,7 +368,7 @@ class session_redis_test extends \advanced_testcase {
/**
* Assert that we don't have any session locks in Redis.
*/
protected function assertSessionNoLocks() {
protected function assert_session_no_locks(): void {
$this->assertEmpty($this->redis->keys($this->keyprefix.'*.lock'));
}
@ -375,4 +383,208 @@ class session_redis_test extends \advanced_testcase {
$this->assertEquals($CFG->session_redis_encrypt, $prop->getValue($sess));
}
/**
* Test the get maxlifetime method.
*/
public function test_get_maxlifetime(): void {
global $CFG;
// Set the timeout to something known for the test.
set_config('sessiontimeout', 100);
// Generate a test user.
$user = $this->getDataGenerator()->create_user();
// Create a new redis session object.
$session = new \core\session\redis();
$session->init();
// The get_maxlifetime is private, so we need to use reflection to access it.
$method = new \ReflectionMethod(\core\session\redis::class, 'get_maxlifetime');
// Test guest timeout, which should be longer.
$result = $method->invoke($session, $CFG->siteguest);
$this->assertEquals(500, $result);
// Test first access timeout.
$result = $method->invoke($session, 0, true);
$this->assertEquals(180, $result);
// Test with a real user.
$result = $method->invoke($session, $user->id);
$this->assertEquals(180, $result);
}
/**
* Test the add session method.
*/
public function test_add_session(): void {
// Set the timeout to something known for the test.
set_config('sessiontimeout', 100);
// Generate a test user.
$user = $this->getDataGenerator()->create_user();
// Create a new redis session object.
$session = new \core\session\redis();
$session->init();
// Create two sessions for the user.
session_id('id1');
$session1data = $session->add_session($user->id);
session_id('id2');
$session2data = $session->add_session($user->id);
$session1 = $session->get_session_by_sid('id1');
$session2 = $session->get_session_by_sid('id2');
// Assert that the sessions were created and have expected data.
$this->assertEqualsCanonicalizing((array)$session1data, (array)$session1);
$this->assertEqualsCanonicalizing((array)$session2data, (array)$session2);
// Check that the session hash has a ttl set.
$this->assertGreaterThan(-1, $this->redis->ttl($this->keyprefix . 'session_id1'));
// Check that the session ttl is less or equal to what we set it.
$this->assertLessThanOrEqual(180, $this->redis->ttl($this->keyprefix . 'session_id1'));
}
/**
* Test writing session data.
*/
public function test_write(): void {
// Set the timeout to something known for the test.
set_config('sessiontimeout', 100);
// Generate a test user.
$user = $this->getDataGenerator()->create_user();
// Create a new redis session object.
$session = new \core\session\redis();
$session->init();
// Create two sessions for the user.
session_id('id1');
$session->add_session($user->id);
session_id('id2');
$session->add_session($user->id);
$testdata = 'some test data';
// Write some data to the store.
$result = $session->write('id2', $testdata);
// Check that the write was successful.
$this->assertTrue($result);
// Check that the data was written to the store.
$getdata = $this->redis->hget($this->keyprefix . 'session_id2', 'sessdata');
$this->assertStringContainsString($testdata, $getdata);
}
/**
* Test reading session data.
*/
public function test_read(): void {
// Set the timeout to something known for the test.
set_config('sessiontimeout', 100);
// Generate a test user.
$user = $this->getDataGenerator()->create_user();
// Create a new redis session object.
$session = new \core\session\redis();
$session->init();
// Create two sessions for the user.
session_id('id1');
$session->add_session($user->id);
session_id('id2');
$session->add_session($user->id);
$testdata = 'some test data';
// Write some session data to the store.
$session->write('id2', $testdata);
// Read the session data.
$result = $session->read('id2');
// Check that the read was successful.
$this->assertEquals($result, $testdata);
}
/**
* Test updating a session.
*/
public function test_update_session(): void {
// Set the timeout to something known for the test.
set_config('sessiontimeout', 100);
// Generate a test user.
$user = $this->getDataGenerator()->create_user();
// Create a new redis session object.
$session = new \core\session\redis();
$session->init();
// Create two sessions for the user.
session_id('id1');
$session->add_session($user->id);
session_id('id2');
$sessiondata = $session->add_session($user->id);
// Update the session data.
$sessiondata->lastip = '8.8.8.8';
$session->update_session($sessiondata);
// Check the value was updated.
$updatedsession = $session->get_session_by_sid('id2');
$this->assertEquals('8.8.8.8', $updatedsession->lastip);
// Test session update when userid is not set, should not error.
unset($sessiondata->userid);
$session->update_session($sessiondata);
$this->assertDebuggingNotCalled();
}
/**
* Test destroying a session by auth plugin.
*/
public function test_destroy_by_auth_plugin(): void {
// Create test users.
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user(['auth' => 'db']);
// Create a new redis session object.
$session = new \core\session\redis();
$session->init();
// Create sessions for the users.
session_id('id1');
$session1data = $session->add_session($user1->id);
session_id('id2');
$session2data = $session->add_session($user2->id);
$session1 = $session->get_session_by_sid('id1');
$session2 = $session->get_session_by_sid('id2');
// Assert that the sessions were created and have expected data.
$this->assertEqualsCanonicalizing((array) $session1data, (array) $session1);
$this->assertEqualsCanonicalizing((array) $session2data, (array) $session2);
// Destroy the session by auth plugin.
$session->destroy_by_auth_plugin('manual');
// Check that the session was destroyed.
$this->assertFalse($session->session_exists('id1'));
// Check the session with db auth plugin was not destroyed.
$this->assertTrue($session->session_exists('id2'));
}
}

View File

@ -119,7 +119,7 @@ if ($mform->is_cancelled()) {
// Log out all other sessions if mandated by admin, or if set by the user.
if (!empty($CFG->passwordchangelogout) || !empty($data->logoutothersessions)) {
\core\session\manager::kill_user_sessions($USER->id, session_id());
\core\session\manager::destroy_user_sessions($USER->id, session_id());
}
if (!empty($data->signoutofotherservices)) {

View File

@ -288,7 +288,7 @@ function core_login_process_password_set($token) {
}
user_add_password_history($user->id, $data->password);
if (!empty($CFG->passwordchangelogout) || !empty($data->logoutothersessions)) {
\core\session\manager::kill_user_sessions($user->id, session_id());
\core\session\manager::destroy_user_sessions($user->id, session_id());
}
// Reset login lockout (if present) before a new password is set.
login_unlock_account($user);

View File

@ -77,15 +77,15 @@ function report_usersessions_format_ip($ip) {
* @param int $id
* @return void
*/
function report_usersessions_kill_session($id) {
global $DB, $USER;
function report_usersessions_kill_session(int $id): void {
global $USER;
$session = $DB->get_record('sessions', array('id' => $id, 'userid' => $USER->id), 'id, sid');
$sessions = \core\session\manager::get_sessions_by_userid($USER->id);
$filteredsessions = array_filter($sessions, fn ($session) => $session->id === $id);
if (!$session or $session->sid === session_id()) {
// Do not delete the current session!
return;
foreach ($filteredsessions as $session) {
if ($session->sid !== session_id()) {
\core\session\manager::destroy($session->sid);
}
}
\core\session\manager::kill_session($session->sid);
}

View File

@ -64,7 +64,7 @@ if ($delete && confirm_sesskey()) {
// Delete all sessions except current.
if ($deleteall && confirm_sesskey()) {
\core\session\manager::kill_user_sessions($USER->id, session_id());
\core\session\manager::destroy_user_sessions($USER->id, session_id());
redirect(
url: $PAGE->url,
message: get_string('logoutothersessionssuccess', 'report_usersessions'),
@ -82,15 +82,13 @@ echo $OUTPUT->header();
echo $OUTPUT->heading(get_string('mysessions', 'report_usersessions'));
$data = array();
$sql = "SELECT id, timecreated, timemodified, firstip, lastip, sid
FROM {sessions}
WHERE userid = :userid
ORDER BY timemodified DESC";
$params = array('userid' => $USER->id, 'sid' => session_id());
$sessions = $DB->get_records_sql($sql, $params);
$sessions = \core\session\manager::get_sessions_by_userid($USER->id);
// Order records by timemodified DESC.
usort($sessions, function($a, $b){
return $b->timemodified <=> $a->timemodified;
});
foreach ($sessions as $session) {
if ($session->sid === $params['sid']) {
if ($session->sid === session_id()) {
$lastaccess = get_string('thissession', 'report_usersessions');
$deletelink = '';

View File

@ -294,7 +294,10 @@ class provider implements
// Delete user course requests.
$DB->delete_records('course_request', ['requester' => $userid]);
// Delete sessions.
$DB->delete_records('sessions', ['userid' => $userid]);
$sessions = \core\session\manager::get_sessions_by_userid($userid);
foreach ($sessions as $session) {
\core\session\manager::destroy($session->sid);
}
// Do I delete user preferences? Seems like the right place to do it.
$DB->delete_records('user_preferences', ['userid' => $userid]);
@ -528,7 +531,7 @@ class provider implements
protected static function export_user_session_data(int $userid, \context $context) {
global $DB, $SESSION;
$records = $DB->get_records('sessions', ['userid' => $userid]);
$records = \core\session\manager::get_sessions_by_userid($userid);
if (!empty($records)) {
$sessiondata = (object) array_map(function($record) {
return [

View File

@ -233,7 +233,7 @@ if ($userform->is_cancelled()) {
if (!empty($CFG->passwordchangelogout)) {
// We can use SID of other user safely here because they are unique,
// the problem here is we do not want to logout admin here when changing own password.
\core\session\manager::kill_user_sessions($usernew->id, session_id());
\core\session\manager::destroy_user_sessions($usernew->id, session_id());
}
if (!empty($usernew->signoutofotherservices)) {
webservice::delete_user_ws_tokens($usernew->id);
@ -243,7 +243,7 @@ if ($userform->is_cancelled()) {
// Force logout if user just suspended.
if (isset($usernew->suspended) and $usernew->suspended and !$user->suspended) {
\core\session\manager::kill_user_sessions($user->id);
\core\session\manager::destroy_user_sessions($user->id);
}
}

View File

@ -659,7 +659,7 @@ class core_user_external extends \core_external\external_api {
useredit_update_user_preference($userpref);
}
if (isset($user['suspended']) and $user['suspended']) {
\core\session\manager::kill_user_sessions($user['id']);
\core\session\manager::destroy_user_sessions($user['id']);
}
$transaction->allow_commit();

View File

@ -13,33 +13,30 @@
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy tests for core_user.
*
* @package core_user
* @category test
* @copyright 2018 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_user\privacy;
defined('MOODLE_INTERNAL') || die();
global $CFG;
use \core_privacy\tests\provider_testcase;
use \core_user\privacy\provider;
use \core_privacy\local\request\approved_userlist;
use \core_privacy\local\request\transform;
require_once($CFG->dirroot . "/user/lib.php");
use core\tests\session\mock_handler;
use core_privacy\tests\provider_testcase;
use core_privacy\local\request\approved_userlist;
use core_privacy\local\request\transform;
use core_user\privacy\provider;
/**
* Unit tests for core_user.
*
* @package core_user
* @copyright 2018 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_user\privacy\provider
*/
class provider_test extends provider_testcase {
final class provider_test extends provider_testcase {
public static function setUpBeforeClass(): void {
global $CFG;
parent::setUpBeforeClass();
require_once($CFG->dirroot . "/user/lib.php");
}
/**
* Check that context information is returned correctly.
@ -475,7 +472,8 @@ class provider_test extends provider_testcase {
'firstip' => '0.0.0.0',
'lastip' => '0.0.0.0'
];
$DB->insert_record('sessions', $usersessions);
$mockhandler = new mock_handler();
$mockhandler->add_test_session($usersessions);
}
/**

View File

@ -213,7 +213,7 @@ class core_webservice_external extends \core_external\external_api {
$siteinfo['limitconcurrentlogins'] = (int) $CFG->limitconcurrentlogins;
if (!empty($CFG->limitconcurrentlogins)) {
// For performance, only when enabled.
$siteinfo['usersessionscount'] = $DB->count_records('sessions', ['userid' => $USER->id]);
$siteinfo['usersessionscount'] = count(\core\session\manager::get_sessions_by_userid($USER->id));
}
$siteinfo['policyagreed'] = $USER->policyagreed;

View File

@ -18,6 +18,7 @@ namespace core_webservice;
use core_external\external_api;
use externallib_advanced_testcase;
use core\tests\session\mock_handler;
defined('MOODLE_INTERNAL') || die();
@ -33,8 +34,8 @@ require_once($CFG->dirroot . '/webservice/tests/helpers.php');
* @copyright 2012 Paul Charsley
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class externallib_test extends externallib_advanced_testcase {
final class externallib_test extends externallib_advanced_testcase {
#[\Override]
public function setUp(): void {
// Calling parent is good, always
parent::setUp();
@ -190,7 +191,9 @@ class externallib_test extends externallib_advanced_testcase {
$record->firstip = $record->lastip = '10.0.0.1';
$record->sid = md5('hokus1');
$record->timecreated = time();
$DB->insert_record('sessions', $record);
$mockhandler = new mock_handler();
$mockhandler->add_test_session($record);
$siteinfo = \core_webservice_external::get_site_info();
$siteinfo = external_api::clean_returnvalue(\core_webservice_external::get_site_info_returns(), $siteinfo);