Proyectos de Subversion Moodle

Rev

Rev 1 | Ir a la última revisión | | Comparar con el anterior | Ultima modificación | Ver Log |

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