Suppress legacy session for ACP activity
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / session / SessionHandler.class.php
index e65eb82e7c259f78e82ab16902f59bbcbf51dbe9..b1a744ed80e07807cccbb8d1e6cc3ec4c4800fe9 100644 (file)
 <?php
+
 namespace wcf\system\session;
-use wcf\data\acp\session\virtual\ACPSessionVirtual;
-use wcf\data\acp\session\virtual\ACPSessionVirtualAction;
-use wcf\data\acp\session\virtual\ACPSessionVirtualEditor;
-use wcf\data\session\virtual\SessionVirtual;
-use wcf\data\session\virtual\SessionVirtualAction;
-use wcf\data\session\virtual\SessionVirtualEditor;
+
+use ParagonIE\ConstantTime\Hex;
+use wcf\data\session\Session as LegacySession;
 use wcf\data\session\SessionEditor;
 use wcf\data\user\User;
 use wcf\data\user\UserEditor;
+use wcf\system\application\ApplicationHandler;
 use wcf\system\cache\builder\SpiderCacheBuilder;
 use wcf\system\cache\builder\UserGroupOptionCacheBuilder;
 use wcf\system\cache\builder\UserGroupPermissionCacheBuilder;
 use wcf\system\database\DatabaseException;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
 use wcf\system\event\EventHandler;
 use wcf\system\exception\PermissionDeniedException;
 use wcf\system\page\PageLocationManager;
-use wcf\system\user\authentication\UserAuthenticationFactory;
-use wcf\system\user\storage\UserStorageHandler;
+use wcf\system\request\RouteHandler;
 use wcf\system\SingletonFactory;
+use wcf\system\user\storage\UserStorageHandler;
 use wcf\system\WCF;
 use wcf\system\WCFACP;
+use wcf\util\CryptoUtil;
 use wcf\util\HeaderUtil;
 use wcf\util\UserUtil;
 
 /**
  * Handles sessions.
- * 
- * @author     Alexander Ebert
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @package    WoltLabSuite\Core\System\Session
  *
- * @property-read      string          $sessionID              unique textual identifier of the session
- * @property-read      integer|null    $userID                 id of the user the session belongs to or `null` if the acp session belongs to a guest
- * @property-read      string          $ipAddress              id of the user whom the session belongs to
- * @property-read      string          $userAgent              user agent of the user whom the session belongs to
- * @property-read      integer         $lastActivityTime       timestamp at which the latest activity occurred
- * @property-read      string          $requestURI             uri of the latest request
- * @property-read      string          $requestMethod          used request method of the latest request (`GET`, `POST`)
- * @property-read      integer|null    $pageID                 id of the latest page visited
- * @property-read      integer|null    $pageObjectID           id of the object the latest page visited belongs to
- * @property-read      integer|null    $parentPageID           id of the parent page of latest page visited
- * @property-read      integer|null    $parentPageObjectID     id of the object the parent page of latest page visited belongs to
- * @property-read      integer         $spiderID               id of the spider the session belongs to
+ * @author  Tim Duesterhus, Alexander Ebert
+ * @copyright   2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Session
+ *
+ * @property-read   string $sessionID      unique textual identifier of the session
+ * @property-read   int|null $userID         id of the user the session belongs to or `null` if the session belongs to a guest
+ * @property-read   int|null $pageID         id of the latest page visited
+ * @property-read   int|null $pageObjectID       id of the object the latest page visited belongs to
+ * @property-read   int|null $parentPageID       id of the parent page of latest page visited
+ * @property-read   int|null $parentPageObjectID id of the object the parent page of latest page visited belongs to
+ * @property-read   int $spiderID       id of the spider the session belongs to
  */
-class SessionHandler extends SingletonFactory {
-       /**
-        * suffix used to tell ACP and frontend cookies apart
-        * @var string
-        */
-       protected $cookieSuffix = '';
-       
-       /**
-        * prevents update on shutdown
-        * @var boolean
-        */
-       protected $doNotUpdate = false;
-       
-       /**
-        * disables page tracking
-        * @var boolean
-        */
-       protected $disableTracking = false;
-       
-       /**
-        * various environment variables
-        * @var array
-        */
-       protected $environment = [];
-       
-       /**
-        * group data and permissions
-        * @var mixed[][]
-        */
-       protected $groupData = null;
-       
-       /**
-        * true if client provided a valid session cookie
-        * @var boolean
-        */
-       protected $hasValidCookie = false;
-       
-       /**
-        * true if within ACP or WCFSetup
-        * @var boolean
-        */
-       protected $isACP = false;
-       
-       /**
-        * language id for active user
-        * @var integer
-        */
-       protected $languageID = 0;
-       
-       /**
-        * language ids for active user
-        * @var integer[]
-        */
-       protected $languageIDs = null;
-       
-       /**
-        * session object
-        * @var \wcf\data\acp\session\ACPSession
-        */
-       protected $session = null;
-       
-       /**
-        * session class name
-        * @var string
-        */
-       protected $sessionClassName = '';
-       
-       /**
-        * session editor class name
-        * @var string
-        */
-       protected $sessionEditorClassName = '';
-       
-       /**
-        * virtual session support
-        * @var boolean
-        */
-       protected $supportsVirtualSessions = false;
-       
-       /**
-        * style id
-        * @var integer
-        */
-       protected $styleID = null;
-       
-       /**
-        * user object
-        * @var User
-        */
-       protected $user = null;
-       
-       /**
-        * session variables
-        * @var array
-        */
-       protected $variables = null;
-       
-       /**
-        * indicates if session variables changed and must be saved upon shutdown
-        * @var boolean
-        */
-       protected $variablesChanged = false;
-       
-       /**
-        * virtual session object, null for guests
-        * @var \wcf\data\session\virtual\SessionVirtual
-        */
-       protected $virtualSession = false;
-       
-       /**
-        * true if this is a new session
-        * @var boolean
-        */
-       protected $firstVisit = false;
-       
-       /**
-        * list of names of permissions only available for users
-        * @var string[]
-        */
-       protected $usersOnlyPermissions = [];
-       
-       /**
-        * Provides access to session data.
-        * 
-        * @param       string          $key
-        * @return      mixed
-        */
-       public function __get($key) {
-               if (isset($this->environment[$key])) {
-                       return $this->environment[$key];
-               }
-               
-               return $this->session->{$key};
-       }
-       
-       /**
-        * @inheritDoc
-        */
-       protected function init() {
-               $this->isACP = (class_exists(WCFACP::class, false) || !PACKAGE_ID);
-               $this->usersOnlyPermissions = UserGroupOptionCacheBuilder::getInstance()->getData([], 'usersOnlyOptions');
-       }
-       
-       /**
-        * Suffix used to tell ACP and frontend cookies apart
-        * 
-        * @param       string  $cookieSuffix   cookie suffix
-        */
-       public function setCookieSuffix($cookieSuffix) {
-               $this->cookieSuffix = $cookieSuffix;
-       }
-       
-       /**
-        * Sets a boolean value to determine if the client provided a valid session cookie.
-        * 
-        * @param       boolean         $hasValidCookie
-        * @since       3.0
-        */
-       public function setHasValidCookie($hasValidCookie) {
-               $this->hasValidCookie = $hasValidCookie;
-       }
-       
-       /**
-        * Returns true if client provided a valid session cookie.
-        * 
-        * @return      boolean
-        * @since       3.0
-        */
-       public function hasValidCookie() {
-               return $this->hasValidCookie;
-       }
-       
-       /**
-        * Loads an existing session or creates a new one.
-        * 
-        * @param       string          $sessionEditorClassName
-        * @param       string          $sessionID
-        */
-       public function load($sessionEditorClassName, $sessionID) {
-               $this->sessionEditorClassName = $sessionEditorClassName;
-               $this->sessionClassName = call_user_func([$sessionEditorClassName, 'getBaseClass']);
-               $this->supportsVirtualSessions = call_user_func([$this->sessionClassName, 'supportsVirtualSessions']);
-               
-               // try to get existing session
-               if (!empty($sessionID)) {
-                       $this->getExistingSession($sessionID);
-               }
-               
-               // create new session
-               if ($this->session === null) {
-                       $this->create();
-               }
-       }
-       
-       /**
-        * Initializes session system.
-        */
-       public function initSession() {
-               // init session environment
-               $this->loadVariables();
-               $this->initSecurityToken();
-               
-               // session id change was delayed to the next request
-               // as the SID constants already were defined
-               if ($this->getVar('__changeSessionID')) {
-                       $this->unregister('__changeSessionID');
-                       $this->changeSessionID();
-               }
-               $this->defineConstants();
-               
-               // assign language and style id
-               $this->languageID = ($this->getVar('languageID') === null) ? $this->user->languageID : $this->getVar('languageID');
-               $this->styleID = ($this->getVar('styleID') === null) ? $this->user->styleID : $this->getVar('styleID');
-               
-               // init environment variables
-               $this->initEnvironment();
-               
-               // https://github.com/WoltLab/WCF/issues/2568
-               if ($this->getVar('__wcfIsFirstVisit') === true) {
-                       $this->firstVisit = true;
-                       $this->unregister('__wcfIsFirstVisit');
-               }
-       }
-       
-       /**
-        * Changes the session id to a new random one.
-        * 
-        * Usually a change is requested after login to ensure
-        * that the user is not running a fixated session by an
-        * attacker.
-        */
-       protected function changeSessionID() {
-               $newSessionID = bin2hex(\random_bytes(20));
-               
-               /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
-               $sessionEditor = new $this->sessionEditorClassName($this->session);
-               $sessionEditor->update([
-                       'sessionID' => $newSessionID
-               ]);
-               
-               // fetch new session data from database
-               $this->session = new $this->sessionClassName($newSessionID);
-               
-               HeaderUtil::setCookie('cookieHash'.$this->cookieSuffix, $newSessionID);
-       }
-       
-       /**
-        * Initializes environment variables.
-        */
-       protected function initEnvironment() {
-               $this->environment = [
-                       'lastRequestURI' => $this->session->requestURI,
-                       'lastRequestMethod' => $this->session->requestMethod,
-                       'ipAddress' => UserUtil::getIpAddress(),
-                       'userAgent' => UserUtil::getUserAgent(),
-                       'requestURI' => UserUtil::getRequestURI(),
-                       'requestMethod' => !empty($_SERVER['REQUEST_METHOD']) ? substr($_SERVER['REQUEST_METHOD'], 0, 7) : ''
-               ];
-       }
-       
-       /**
-        * Disables update on shutdown.
-        */
-       public function disableUpdate() {
-               $this->doNotUpdate = true;
-       }
-       
-       /**
-        * Disables page tracking.
-        */
-       public function disableTracking() {
-               $this->disableTracking = true;
-       }
-       
-       /**
-        * Defines global wcf constants related to session.
-        */
-       protected function defineConstants() {
-               /* the SID*-constants below are deprecated since 3.0 */
-               if (!defined('SID_ARG_1ST')) define('SID_ARG_1ST', '');
-               if (!defined('SID_ARG_2ND')) define('SID_ARG_2ND', '');
-               if (!defined('SID_ARG_2ND_NOT_ENCODED')) define('SID_ARG_2ND_NOT_ENCODED', '');
-               if (!defined('SID')) define('SID', '');
-               if (!defined('SID_INPUT_TAG')) define('SID_INPUT_TAG', '');
-               
-               // security token
-               if (!defined('SECURITY_TOKEN')) define('SECURITY_TOKEN', $this->getSecurityToken());
-               if (!defined('SECURITY_TOKEN_INPUT_TAG')) define('SECURITY_TOKEN_INPUT_TAG', '<input type="hidden" name="t" value="'.$this->getSecurityToken().'">');
-       }
-       
-       /**
-        * Initializes security token.
-        */
-       protected function initSecurityToken() {
-               if ($this->getVar('__SECURITY_TOKEN') === null) {
-                       $this->register('__SECURITY_TOKEN', bin2hex(\random_bytes(20)));
-               }
-       }
-       
-       /**
-        * Returns security token.
-        * 
-        * @return      string
-        */
-       public function getSecurityToken() {
-               return $this->getVar('__SECURITY_TOKEN');
-       }
-       
-       /**
-        * Validates the given security token, returns false if
-        * given token is invalid.
-        * 
-        * @param       string          $token
-        * @return      boolean
-        */
-       public function checkSecurityToken($token) {
-               return \hash_equals($this->getSecurityToken(), $token);
-       }
-       
-       /**
-        * Registers a session variable.
-        * 
-        * @param       string          $key
-        * @param       mixed           $value
-        */
-       public function register($key, $value) {
-               $this->variables[$key] = $value;
-               $this->variablesChanged = true;
-       }
-       
-       /**
-        * Unsets a session variable.
-        * 
-        * @param       string          $key
-        */
-       public function unregister($key) {
-               unset($this->variables[$key]);
-               $this->variablesChanged = true;
-       }
-       
-       /**
-        * Returns the value of a session variable or `null` if the session
-        * variable does not exist.
-        * 
-        * @param       string          $key
-        * @return      mixed
-        */
-       public function getVar($key) {
-               if (isset($this->variables[$key])) {
-                       return $this->variables[$key];
-               }
-               
-               return null;
-       }
-       
-       /**
-        * Initializes session variables.
-        */
-       protected function loadVariables() {
-               if ($this->session->sessionVariables !== null) {
-                       $this->variables = @unserialize($this->session->sessionVariables);
-                       if (!is_array($this->variables)) {
-                               $this->variables = [];
-                       }
-               }
-               else {
-                       $this->variables = [];
-               }
-       }
-       
-       /**
-        * Returns the user object of this session.
-        * 
-        * @return      User    $user
-        */
-       public function getUser() {
-               return $this->user;
-       }
-       
-       /**
-        * Tries to read existing session identified by the given session id.
-        * 
-        * @param       string          $sessionID
-        */
-       protected function getExistingSession($sessionID) {
-               $this->session = new $this->sessionClassName($sessionID);
-               if (!$this->session->sessionID) {
-                       $this->session = null;
-                       return;
-               }
-               
-               $this->user = new User($this->session->userID);
-               if ($this->isACP) {
-                       $this->virtualSession = ACPSessionVirtual::getExistingSession($sessionID);
-               }
-               else {
-                       $this->virtualSession = SessionVirtual::getExistingSession($sessionID);
-               }
-               
-               if (!$this->validate()) {
-                       $this->session = null;
-                       $this->user = null;
-                       $this->virtualSession = false;
-                       
-                       return;
-               }
-               
-               $this->loadVirtualSession();
-       }
-       
-       /**
-        * Loads the virtual session object unless the user is not logged in or the session
-        * does not support virtual sessions. If there is no virtual session yet, it will be
-        * created on-the-fly.
-        * 
-        * @param       boolean         $forceReload
-        */
-       protected function loadVirtualSession($forceReload = false) {
-               if ($this->virtualSession === null || $forceReload) {
-                       $this->virtualSession = null;
-                       if ($this->isACP) {
-                               $virtualSessionAction = new ACPSessionVirtualAction([], 'create', ['data' => ['sessionID' => $this->session->sessionID]]);
-                       }
-                       else {
-                               $virtualSessionAction = new SessionVirtualAction([], 'create', ['data' => ['sessionID' => $this->session->sessionID]]);
-                       }
-                       
-                       try {
-                               $returnValues = $virtualSessionAction->executeAction();
-                               $this->virtualSession = $returnValues['returnValues'];
-                       }
-                       catch (DatabaseException $e) {
-                               // MySQL error 23000 = unique key
-                               // do not check against the message itself, some weird systems localize them
-                               if ($e->getCode() == 23000) {
-                                       if ($this->isACP) {
-                                               $this->virtualSession = ACPSessionVirtual::getExistingSession($this->session->sessionID);
-                                       }
-                                       else {
-                                               $this->virtualSession = SessionVirtual::getExistingSession($this->session->sessionID);
-                                       }
-                               }
-                       }
-               }
-       }
-       
-       /**
-        * Validates the ip address and the user agent of this session.
-        * 
-        * @return      boolean
-        */
-       protected function validate() {
-               if (SESSION_VALIDATE_IP_ADDRESS) {
-                       if ($this->virtualSession instanceof ACPSessionVirtual) {
-                               if ($this->virtualSession->ipAddress != UserUtil::getIpAddress()) {
-                                       return false;
-                               }
-                       }
-                       else if ($this->session->ipAddress != UserUtil::getIpAddress()) {
-                               return false;
-                       }
-               }
-               
-               if (SESSION_VALIDATE_USER_AGENT) {
-                       if ($this->virtualSession instanceof ACPSessionVirtual) {
-                               if ($this->virtualSession->userAgent != UserUtil::getUserAgent()) {
-                                       return false;
-                               }
-                       }
-                       else if ($this->session->userAgent != UserUtil::getUserAgent()) {
-                               return false;
-                       }
-               }
-               
-               return true;
-       }
-       
-       /**
-        * Creates a new session.
-        */
-       protected function create() {
-               $spiderID = null;
-               if ($this->sessionEditorClassName == SessionEditor::class) {
-                       // get spider information
-                       $spiderID = $this->getSpiderID(UserUtil::getUserAgent());
-                       if ($spiderID !== null) {
-                               // try to use existing session
-                               if (($session = $this->getExistingSpiderSession($spiderID)) !== null) {
-                                       $this->user = new User(null);
-                                       $this->session = $session;
-                                       return;
-                               }
-                       }
-               }
-               
-               // create new session hash
-               $sessionID = bin2hex(\random_bytes(20));
-               
-               // get user automatically
-               $this->user = UserAuthenticationFactory::getInstance()->getUserAuthentication()->loginAutomatically(call_user_func([$this->sessionClassName, 'supportsPersistentLogins']));
-               
-               // create user
-               if ($this->user === null) {
-                       // no valid user found
-                       // create guest user
-                       $this->user = new User(null);
-               }
-               else if (!$this->supportsVirtualSessions) {
-                       // delete all other sessions of this user
-                       call_user_func([$this->sessionEditorClassName, 'deleteUserSessions'], [$this->user->userID]);
-               }
-               
-               $createNewSession = true;
-               // find existing session
-               $session = call_user_func([$this->sessionClassName, 'getSessionByUserID'], $this->user->userID);
-               
-               if ($session !== null) {
-                       // inherit existing session
-                       $this->session = $session;
-                       $this->loadVirtualSession(true);
-                               
-                       $createNewSession = false;
-               }
-               
-               if ($createNewSession) {
-                       // save session
-                       $sessionData = [
-                               'sessionID' => $sessionID,
-                               'userID' => $this->user->userID,
-                               'ipAddress' => UserUtil::getIpAddress(),
-                               'userAgent' => UserUtil::getUserAgent(),
-                               'lastActivityTime' => TIME_NOW,
-                               'requestURI' => UserUtil::getRequestURI(),
-                               'requestMethod' => !empty($_SERVER['REQUEST_METHOD']) ? substr($_SERVER['REQUEST_METHOD'], 0, 7) : ''
-                       ];
-                       
-                       if ($spiderID !== null) $sessionData['spiderID'] = $spiderID;
-                       
-                       try {
-                               $this->session = call_user_func([$this->sessionEditorClassName, 'create'], $sessionData);
-                       }
-                       catch (DatabaseException $e) {
-                               // MySQL error 23000 = unique key
-                               // do not check against the message itself, some weird systems localize them
-                               if ($e->getCode() == 23000) {
-                                       // find existing session
-                                       $session = call_user_func([$this->sessionClassName, 'getSessionByUserID'], $this->user->userID);
-                                       
-                                       if ($session === null) {
-                                               // MySQL reported a unique key error, but no corresponding session exists, rethrow exception
-                                               throw $e;
-                                       }
-                                       else {
-                                               // inherit existing session
-                                               $this->session = $session;
-                                               $this->loadVirtualSession(true);
-                                       }
-                               }
-                               else {
-                                       // unrelated to user id
-                                       throw $e;
-                               }
-                       }
-                       
-                       $this->firstVisit = true;
-                       $this->loadVirtualSession(true);
-               }
-       }
-       
-       /**
-        * Returns the value of the permission with the given name.
-        * 
-        * @param       string          $permission
-        * @return      mixed           permission value
-        */
-       public function getPermission($permission) {
-               // check if a users only permission is checked for a guest and return
-               // false if that is the case
-               if (!$this->user->userID && in_array($permission, $this->usersOnlyPermissions)) {
-                       return false;
-               }
-               
-               $this->loadGroupData();
-               
-               if (!isset($this->groupData[$permission])) return false;
-               return $this->groupData[$permission];
-       }
-       
-       /**
-        * Returns true if a permission was set to 'Never'. This is required to preserve
-        * compatibility, while preventing ACLs from overruling a 'Never' setting.
-        * 
-        * @param       string          $permission
-        * @return      boolean
-        */
-       public function getNeverPermission($permission) {
-               $this->loadGroupData();
-               
-               return (isset($this->groupData['__never'][$permission]));
-       }
-       
-       /**
-        * Checks if the active user has the given permissions and throws a
-        * PermissionDeniedException if that isn't the case.
-        * 
-        * @param       string[]        $permissions    list of permissions where each one must pass
-        * @throws      PermissionDeniedException
-        */
-       public function checkPermissions(array $permissions) {
-               foreach ($permissions as $permission) {
-                       if (!$this->getPermission($permission)) {
-                               throw new PermissionDeniedException();
-                       }
-               }
-       }
-       
-       /**
-        * Loads group data from cache.
-        */
-       protected function loadGroupData() {
-               if ($this->groupData !== null) return;
-               
-               // work-around for setup process (package wcf does not exist yet)
-               if (!PACKAGE_ID) {
-                       $sql = "SELECT  groupID
-                               FROM    wcf".WCF_N."_user_to_group
-                               WHERE   userID = ?";
-                       $statement = WCF::getDB()->prepareStatement($sql);
-                       $statement->execute([$this->user->userID]);
-                       $groupIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
-               }
-               else {
-                       $groupIDs = $this->user->getGroupIDs();
-               }
-               
-               // get group data from cache
-               $this->groupData = UserGroupPermissionCacheBuilder::getInstance()->getData($groupIDs);
-               if (isset($this->groupData['groupIDs']) && $this->groupData['groupIDs'] != $groupIDs) {
-                       $this->groupData = [];
-               }
-       }
-       
-       /**
-        * Returns language ids for active user.
-        * 
-        * @return      integer[]
-        */
-       public function getLanguageIDs() {
-               $this->loadLanguageIDs();
-               
-               return $this->languageIDs;
-       }
-       
-       /**
-        * Loads language ids for active user.
-        */
-       protected function loadLanguageIDs() {
-               if ($this->languageIDs !== null) return;
-               
-               $this->languageIDs = [];
-               
-               if (!$this->user->userID) {
-                       return;
-               }
-               
-               // work-around for setup process (package wcf does not exist yet)
-               if (!PACKAGE_ID) {
-                       $sql = "SELECT  languageID
-                               FROM    wcf".WCF_N."_user_to_language
-                               WHERE   userID = ?";
-                       $statement = WCF::getDB()->prepareStatement($sql);
-                       $statement->execute([$this->user->userID]);
-                       $this->languageIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
-               }
-               else {
-                       $this->languageIDs = $this->user->getLanguageIDs();
-               }
-       }
-       
-       /**
-        * Stores a new user object in this session, e.g. a user was guest because not
-        * logged in, after the login his old session is used to store his full data.
-        * 
-        * @param       User            $user
-        * @param       boolean         $hideSession    if true, database won't be updated
-        */
-       public function changeUser(User $user, $hideSession = false) {
-               $eventParameters = ['user' => $user, 'hideSession' => $hideSession];
-               
-               EventHandler::getInstance()->fireAction($this, 'beforeChangeUser', $eventParameters);
-               
-               $user = $eventParameters['user'];
-               $hideSession = $eventParameters['hideSession'];
-               
-               // skip changeUserVirtual, if session will not be persistent anyway
-               if (!$hideSession) {
-                       $this->changeUserVirtual($user);
-               }
-               
-               // update user reference
-               $this->user = $user;
-               $this->userID = $this->user->userID ?: 0;
-               
-               // reset caches
-               $this->groupData = null;
-               $this->languageIDs = null;
-               $this->languageID = $this->user->languageID;
-               $this->styleID = $this->user->styleID;
-               
-               // change language
-               WCF::setLanguage($this->languageID ?: 0);
-               
-               // in some cases the language id can be stuck in the session variables
-               $this->unregister('languageID');
-               
-               EventHandler::getInstance()->fireAction($this, 'afterChangeUser');
-       }
-       
-       /**
-        * Changes the user stored in the session.
-        * 
-        * @param       User    $user
-        * @throws      DatabaseException
-        */
-       protected function changeUserVirtual(User $user) {
-               /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
-               
-               switch ($user->userID) {
-                       //
-                       // user -> guest (logout)
-                       //
-                       case 0:
-                               // delete virtual session
-                               if ($this->virtualSession) {
-                                       if ($this->isACP) {
-                                               $virtualSessionEditor = new ACPSessionVirtualEditor($this->virtualSession);
-                                       }
-                                       else {
-                                               $virtualSessionEditor = new SessionVirtualEditor($this->virtualSession);
-                                       }
-                                       $virtualSessionEditor->delete();
-                               }
-                               
-                               if ($this->isACP) {
-                                       $sessionCount = ACPSessionVirtual::countVirtualSessions($this->session->sessionID);
-                               }
-                               else {
-                                       $sessionCount = SessionVirtual::countVirtualSessions($this->session->sessionID);
-                               }
-                               
-                               // there are still other virtual sessions, create a new session
-                               if ($sessionCount) {
-                                       // save session
-                                       $sessionData = [
-                                               'sessionID' => bin2hex(\random_bytes(20)),
-                                               'userID' => $user->userID,
-                                               'ipAddress' => UserUtil::getIpAddress(),
-                                               'userAgent' => UserUtil::getUserAgent(),
-                                               'lastActivityTime' => TIME_NOW,
-                                               'requestURI' => UserUtil::getRequestURI(),
-                                               'requestMethod' => !empty($_SERVER['REQUEST_METHOD']) ? substr($_SERVER['REQUEST_METHOD'], 0, 7) : ''
-                                       ];
-                                       
-                                       $this->session = call_user_func([$this->sessionEditorClassName, 'create'], $sessionData);
-                                       
-                                       HeaderUtil::setCookie('cookieHash'.$this->cookieSuffix, $this->session->sessionID);
-                               }
-                               else {
-                                       // this was the last virtual session, re-use current session
-                                       // update session
-                                       $sessionEditor = new $this->sessionEditorClassName($this->session);
-                                       $sessionEditor->update([
-                                               'userID' => $user->userID
-                                       ]);
-                               }
-                       break;
-                       
-                       //
-                       // guest -> user (login)
-                       //
-                       default:
-                               if (!$this->supportsVirtualSessions) {
-                                       // delete all other sessions of this user
-                                       call_user_func([$this->sessionEditorClassName, 'deleteUserSessions'], [$user->userID]);
-                               }
-                               
-                               // find existing session for this user
-                               $session = call_user_func([$this->sessionClassName, 'getSessionByUserID'], $user->userID);
-                               
-                               // no session exists, re-use current session
-                               if ($session === null) {
-                                       // update session
-                                       $sessionEditor = new $this->sessionEditorClassName($this->session);
-                                       
-                                       try {
-                                               $this->register('__changeSessionID', true);
-                                               
-                                               $sessionEditor->update([
-                                                       'userID' => $user->userID
-                                               ]);
-                                       }
-                                       catch (DatabaseException $e) {
-                                               // MySQL error 23000 = unique key
-                                               // do not check against the message itself, some weird systems localize them
-                                               if ($e->getCode() == 23000) {
-                                                       // delete guest session
-                                                       $sessionEditor = new $this->sessionEditorClassName($this->session);
-                                                       $sessionEditor->delete();
-                                                       
-                                                       // inherit existing session
-                                                       $this->session = $session;
-                                               }
-                                               else {
-                                                       // not our business
-                                                       throw $e;
-                                               }
-                                       }
-                               }
-                               else {
-                                       // delete guest session
-                                       $sessionEditor = new $this->sessionEditorClassName($this->session);
-                                       $sessionEditor->delete();
-                                       
-                                       // inherit existing session
-                                       $this->session = $session;
-                                       
-                                       // inherit security token
-                                       $variables = @unserialize($this->session->sessionVariables);
-                                       if (is_array($variables) && !empty($variables['__SECURITY_TOKEN'])) {
-                                               $this->register('__SECURITY_TOKEN', $variables['__SECURITY_TOKEN']);
-                                       }
-                                       
-                                       HeaderUtil::setCookie('cookieHash'.$this->cookieSuffix, $this->session->sessionID);
-                               }
-                       break;
-               }
-               
-               $this->loadVirtualSession(true);
-       }
-       
-       /**
-        * Updates user session on shutdown.
-        */
-       public function update() {
-               if ($this->doNotUpdate) return;
-               
-               // set up data
-               $data = [
-                       'ipAddress' => UserUtil::getIpAddress(),
-                       'userAgent' => $this->userAgent,
-                       'requestURI' => $this->requestURI,
-                       'requestMethod' => $this->requestMethod,
-                       'lastActivityTime' => TIME_NOW
-               ];
-               if ($this->variablesChanged) {
-                       $data['sessionVariables'] = serialize($this->variables);
-               }
-               if (!class_exists('wcf\system\CLIWCF', false) && !$this->isACP && !$this->disableTracking) {
-                       $pageLocations = PageLocationManager::getInstance()->getLocations();
-                       if (isset($pageLocations[0])) {
-                               $data['pageID'] = $pageLocations[0]['pageID'];
-                               $data['pageObjectID'] = ($pageLocations[0]['pageObjectID'] ?: null);
-                               $data['parentPageID'] = null;
-                               $data['parentPageObjectID'] = null;
-                               
-                               for ($i = 1, $length = count($pageLocations); $i < $length; $i++) {
-                                       if (!empty($pageLocations[$i]['useAsParentLocation'])) {
-                                               $data['parentPageID'] = $pageLocations[$i]['pageID'];
-                                               $data['parentPageObjectID'] = ($pageLocations[$i]['pageObjectID'] ?: null);
-                                               break;
-                                       }
-                               }
-                       }
-               }
-               
-               // update session
-               /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
-               $sessionEditor = new $this->sessionEditorClassName($this->session);
-               $sessionEditor->update($data);
-               
-               if ($this->virtualSession instanceof ACPSessionVirtual) {
-                       if ($this->isACP) {
-                               $virtualSessionEditor = new ACPSessionVirtualEditor($this->virtualSession);
-                       }
-                       else {
-                               $virtualSessionEditor = new SessionVirtualEditor($this->virtualSession);
-                       }
-                       
-                       $virtualSessionEditor->updateLastActivityTime();
-               }
-       }
-       
-       /**
-        * Updates last activity time to protect session from expiring.
-        */
-       public function keepAlive() {
-               $this->disableUpdate();
-               
-               // update last activity time
-               /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
-               $sessionEditor = new $this->sessionEditorClassName($this->session);
-               $sessionEditor->update([
-                       'lastActivityTime' => TIME_NOW
-               ]);
-               
-               if ($this->virtualSession instanceof ACPSessionVirtual) {
-                       if ($this->isACP) {
-                               $virtualSessionEditor = new ACPSessionVirtualEditor($this->virtualSession);
-                       }
-                       else {
-                               $virtualSessionEditor = new SessionVirtualEditor($this->virtualSession);
-                       }
-                       $virtualSessionEditor->updateLastActivityTime();
-               }
-       }
-       
-       /**
-        * Deletes this session and it's related data.
-        */
-       public function delete() {
-               // clear storage
-               if ($this->user->userID) {
-                       self::resetSessions([$this->user->userID]);
-                       
-                       // update last activity time
-                       if (!$this->isACP) {
-                               $editor = new UserEditor($this->user);
-                               $editor->update(['lastActivityTime' => TIME_NOW]);
-                       }
-               }
-               
-               // 1st: Change user to guest, otherwise other the entire session, including
-               // all virtual sessions of the user will be deleted
-               $this->changeUser(new User(null));
-               
-               // 2nd: Actually remove session
-               /** @var \wcf\data\DatabaseObjectEditor $sessionEditor */
-               $sessionEditor = new $this->sessionEditorClassName($this->session);
-               $sessionEditor->delete();
-               
-               // disable update
-               $this->disableUpdate();
-       }
-       
-       /**
-        * Deletes this session if:
-        * - it is newly created in this request, and
-        * - it belongs to a guest.
-        * 
-        * This method is useful if you have controllers that are likely to be
-        * accessed by a user agent that is not going to re-use sessions (e.g.
-        * curl in a cronjob). It immediately remove the session that was created
-        * just for that request and that is not going to be used ever again.
-        * 
-        * @since 5.2
-        */
-       public function deleteIfNew() {
-               if ($this->isFirstVisit() && !$this->getUser()->userID) {
-                       $this->delete();
-               }
-       }
-       
-       /**
-        * Returns currently active language id.
-        * 
-        * @return      integer
-        */
-       public function getLanguageID() {
-               return $this->languageID;
-       }
-       
-       /**
-        * Sets the currently active language id.
-        * 
-        * @param       integer         $languageID
-        */
-       public function setLanguageID($languageID) {
-               $this->languageID = $languageID;
-               $this->register('languageID', $this->languageID);
-       }
-       
-       /**
-        * Returns currently active style id.
-        * 
-        * @return      integer
-        */
-       public function getStyleID() {
-               return $this->styleID;
-       }
-       
-       /**
-        * Sets the currently active style id.
-        * 
-        * @param       integer         $styleID
-        */
-       public function setStyleID($styleID) {
-               $this->styleID = $styleID;
-               $this->register('styleID', $this->styleID);
-       }
-       
-       /**
-        * Resets session-specific storage data.
-        * 
-        * @param       integer[]       $userIDs
-        */
-       public static function resetSessions(array $userIDs = []) {
-               if (!empty($userIDs)) {
-                       UserStorageHandler::getInstance()->reset($userIDs, 'groupIDs');
-                       UserStorageHandler::getInstance()->reset($userIDs, 'languageIDs');
-               }
-               else {
-                       UserStorageHandler::getInstance()->resetAll('groupIDs');
-                       UserStorageHandler::getInstance()->resetAll('languageIDs');
-               }
-       }
-       
-       /**
-        * Returns the spider id for given user agent.
-        * 
-        * @param       string          $userAgent
-        * @return      mixed
-        */
-       protected function getSpiderID($userAgent) {
-               $spiderList = SpiderCacheBuilder::getInstance()->getData();
-               $userAgent = strtolower($userAgent);
-               
-               foreach ($spiderList as $spider) {
-                       if (strpos($userAgent, $spider->spiderIdentifier) !== false) {
-                               return $spider->spiderID;
-                       }
-               }
-               
-               return null;
-       }
-       
-       /**
-        * Searches for existing session of a search spider.
-        * 
-        * @param       integer         $spiderID
-        * @return      \wcf\data\session\Session
-        */
-       protected function getExistingSpiderSession($spiderID) {
-               $sql = "SELECT  *
-                       FROM    wcf".WCF_N."_session
-                       WHERE   spiderID = ?
-                               AND userID IS NULL";
-               $statement = WCF::getDB()->prepareStatement($sql);
-               $statement->execute([$spiderID]);
-               $row = $statement->fetchArray();
-               if ($row !== false) {
-                       // fix session validation
-                       $row['ipAddress'] = UserUtil::getIpAddress();
-                       $row['userAgent'] = UserUtil::getUserAgent();
-                       
-                       // return session object
-                       return new $this->sessionClassName(null, $row);
-               }
-               
-               return null;
-       }
-       
-       /**
-        * Returns true if this is a new session.
-        * 
-        * @return      boolean
-        */
-       public function isFirstVisit() {
-               return $this->firstVisit;
-       }
+final class SessionHandler extends SingletonFactory
+{
+    /**
+     * prevents update on shutdown
+     * @var bool
+     */
+    protected $doNotUpdate = false;
+
+    /**
+     * disables page tracking
+     * @var bool
+     */
+    protected $disableTracking = false;
+
+    /**
+     * group data and permissions
+     * @var mixed[][]
+     */
+    protected $groupData;
+
+    /**
+     * true if within ACP or WCFSetup
+     * @var bool
+     */
+    protected $isACP = false;
+
+    /**
+     * language id for active user
+     * @var int
+     */
+    protected $languageID = 0;
+
+    /**
+     * language ids for active user
+     * @var int[]
+     */
+    protected $languageIDs;
+
+    /**
+     * @var string
+     */
+    private $sessionID;
+
+    /**
+     * @var LegacySession
+     */
+    protected $legacySession;
+
+    /**
+     * style id
+     * @var int
+     */
+    protected $styleID;
+
+    /**
+     * user object
+     * @var User
+     */
+    protected $user;
+
+    /**
+     * session variables
+     * @var array
+     */
+    protected $variables = [];
+
+    /**
+     * indicates if session variables changed and must be saved upon shutdown
+     * @var bool
+     */
+    protected $variablesChanged = false;
+
+    /**
+     * true if this is a new session
+     * @var bool
+     */
+    protected $firstVisit = false;
+
+    /**
+     * list of names of permissions only available for users
+     * @var string[]
+     */
+    protected $usersOnlyPermissions = [];
+
+    /**
+     * @var string
+     */
+    private $xsrfToken;
+
+    private const GUEST_SESSION_LIFETIME = 2 * 3600;
+
+    private const USER_SESSION_LIFETIME = 60 * 86400;
+
+    private const USER_SESSION_LIMIT = 30;
+
+    private const CHANGE_USER_AFTER_MULTIFACTOR_KEY = self::class . "\0__changeUserAfterMultifactor__";
+
+    private const PENDING_USER_LIFETIME = 15 * 60;
+
+    private const REAUTHENTICATION_KEY = self::class . "\0__reauthentication__";
+
+    private const REAUTHENTICATION_HARD_LIMIT = 12 * 3600;
+
+    private const REAUTHENTICATION_SOFT_LIMIT = 2 * 3600;
+
+    private const REAUTHENTICATION_SOFT_LIMIT_ACP = 2 * 3600;
+
+    private const REAUTHENTICATION_GRACE_PERIOD = 15 * 60;
+
+    /**
+     * Provides access to session data.
+     *
+     * @param string $key
+     * @return  mixed
+     */
+    public function __get($key)
+    {
+        switch ($key) {
+            case 'sessionID':
+                return $this->sessionID;
+            case 'userID':
+                return $this->user->userID;
+            case 'spiderID':
+                return $this->getSpiderID(UserUtil::getUserAgent());
+            case 'pageID':
+            case 'pageObjectID':
+            case 'parentPageID':
+            case 'parentPageObjectID':
+                return $this->legacySession->{$key};
+
+            /** @deprecated 5.4 - The below values are deprecated. */
+            case 'ipAddress':
+                return UserUtil::getIpAddress();
+            case 'userAgent':
+                return UserUtil::getUserAgent();
+            case 'requestURI':
+                return UserUtil::getRequestURI();
+            case 'requestMethod':
+                return !empty($_SERVER['REQUEST_METHOD']) ? \substr($_SERVER['REQUEST_METHOD'], 0, 7) : '';
+            case 'lastActivityTime':
+                return TIME_NOW;
+
+            default:
+                return;
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function init()
+    {
+        $this->isACP = (\class_exists(WCFACP::class, false) || !PACKAGE_ID);
+        $this->usersOnlyPermissions = UserGroupOptionCacheBuilder::getInstance()->getData([], 'usersOnlyOptions');
+    }
+
+    /**
+     * @deprecated 5.4 - This method is a noop. The cookie suffix is determined automatically.
+     */
+    public function setCookieSuffix()
+    {
+    }
+
+    /**
+     * @deprecated 5.4 - This method is a noop. Cookie handling works automatically.
+     */
+    public function setHasValidCookie($hasValidCookie)
+    {
+    }
+
+    /**
+     * Parses the session cookie value, returning an array with the stored fields.
+     *
+     * The return array is guaranteed to have a `sessionId` key.
+     */
+    private function parseCookie(string $value): array
+    {
+        $length = \mb_strlen($value, '8bit');
+        if ($length < 1) {
+            throw new \InvalidArgumentException(\sprintf(
+                'Expected at least 1 Byte, %d given.',
+                $length
+            ));
+        }
+
+        $version = \unpack('Cversion', $value)['version'];
+        if (!\in_array($version, [1], true)) {
+            throw new \InvalidArgumentException(\sprintf(
+                'Unknown version %d',
+                $version
+            ));
+        }
+
+        if ($version === 1) {
+            if ($length !== 22) {
+                throw new \InvalidArgumentException(\sprintf(
+                    'Expected exactly 22 Bytes, %d given.',
+                    $length
+                ));
+            }
+            $data = \unpack('Cversion/A20sessionId/Ctimestep', $value);
+            $data['sessionId'] = Hex::encode($data['sessionId']);
+
+            return $data;
+        }
+
+        throw new \LogicException('Unreachable');
+    }
+
+    /**
+     * Extracts the data from the session cookie.
+     *
+     * @see SessionHandler::parseCookie()
+     * @since 5.4
+     */
+    private function getParsedCookieData(): ?array
+    {
+        $cookieName = COOKIE_PREFIX . "user_session";
+
+        if (!empty($_COOKIE[$cookieName])) {
+            if (!PACKAGE_ID) {
+                return [
+                    'sessionId' => $_COOKIE[$cookieName],
+                ];
+            }
+
+            $cookieData = CryptoUtil::getValueFromSignedString($_COOKIE[$cookieName]);
+
+            // Check whether the sessionId was correctly signed.
+            if (!$cookieData) {
+                return null;
+            }
+
+            try {
+                return $this->parseCookie($cookieData);
+            } catch (\InvalidArgumentException $e) {
+                return null;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Returns the session ID stored in the session cookie or `null`.
+     */
+    private function getSessionIdFromCookie(): ?string
+    {
+        $cookieData = $this->getParsedCookieData();
+
+        if ($cookieData) {
+            return $cookieData['sessionId'];
+        }
+
+        return null;
+    }
+
+    /**
+     * Returns the current time step. The time step changes
+     * every 24 hours.
+     */
+    private function getCookieTimestep(): int
+    {
+        $window = (24 * 3600);
+
+        \assert((self::USER_SESSION_LIFETIME / $window) < 0xFF);
+
+        return \floor(TIME_NOW / $window) & 0xFF;
+    }
+
+    /**
+     * Returns the signed session data for use in a cookie.
+     */
+    private function getCookieValue(): string
+    {
+        if (!PACKAGE_ID) {
+            return $this->sessionID;
+        }
+
+        return CryptoUtil::createSignedString(\pack(
+            'CA20C',
+            1,
+            Hex::decode($this->sessionID),
+            $this->getCookieTimestep()
+        ));
+    }
+
+    /**
+     * Returns true if client provided a valid session cookie.
+     *
+     * @return  bool
+     * @since   3.0
+     */
+    public function hasValidCookie(): bool
+    {
+        return $this->getSessionIdFromCookie() === $this->sessionID;
+    }
+
+    /**
+     * @deprecated 5.4 - Sessions are managed automatically. Use loadFromCookie().
+     */
+    public function load($sessionEditorClassName, $sessionID)
+    {
+        $hasSession = false;
+        if (!empty($sessionID)) {
+            $hasSession = $this->getExistingSession($sessionID);
+        }
+
+        if (!$hasSession) {
+            $this->create();
+        }
+    }
+
+    /**
+     * Loads the session matching the session cookie.
+     */
+    public function loadFromCookie()
+    {
+        $sessionID = $this->getSessionIdFromCookie();
+
+        $hasSession = false;
+        if ($sessionID) {
+            $hasSession = $this->getExistingSession($sessionID);
+        }
+
+        if ($hasSession) {
+            $this->maybeRefreshCookie();
+        } else {
+            $this->create();
+        }
+    }
+
+    /**
+     * Refreshes the session cookie, extending the expiry.
+     */
+    private function maybeRefreshCookie(): void
+    {
+        // Guests use short-lived sessions with an actual session cookie.
+        if (!$this->user->userID) {
+            return;
+        }
+
+        $cookieData = $this->getParsedCookieData();
+
+        // No refresh is needed if the timestep matches up.
+        if (isset($cookieData['timestep']) && $cookieData['timestep'] === $this->getCookieTimestep()) {
+            return;
+        }
+
+        // Refresh the cookie.
+        HeaderUtil::setCookie(
+            'user_session',
+            $this->getCookieValue(),
+            TIME_NOW + (self::USER_SESSION_LIFETIME * 2)
+        );
+    }
+
+    /**
+     * Initializes session system.
+     */
+    public function initSession()
+    {
+        $this->defineConstants();
+
+        // assign language and style id
+        $this->languageID = $this->getVar('languageID') ?: $this->user->languageID;
+        $this->styleID = $this->getVar('styleID') ?: $this->user->styleID;
+
+        // https://github.com/WoltLab/WCF/issues/2568
+        if ($this->getVar('__wcfIsFirstVisit') === true) {
+            $this->firstVisit = true;
+            $this->unregister('__wcfIsFirstVisit');
+        }
+    }
+
+    /**
+     * Disables update on shutdown.
+     */
+    public function disableUpdate()
+    {
+        $this->doNotUpdate = true;
+    }
+
+    /**
+     * Disables page tracking.
+     */
+    public function disableTracking()
+    {
+        $this->disableTracking = true;
+    }
+
+    /**
+     * Defines global wcf constants related to session.
+     */
+    protected function defineConstants()
+    {
+        // security token
+        if (!\defined('SECURITY_TOKEN')) {
+            \define('SECURITY_TOKEN', $this->getSecurityToken());
+        }
+        if (!\defined('SECURITY_TOKEN_INPUT_TAG')) {
+            \define(
+                'SECURITY_TOKEN_INPUT_TAG',
+                '<input type="hidden" name="t" value="' . $this->getSecurityToken() . '">'
+            );
+        }
+    }
+
+    /**
+     * Initializes security token.
+     */
+    protected function initSecurityToken()
+    {
+        $xsrfToken = '';
+        if (!empty($_COOKIE['XSRF-TOKEN'])) {
+            // We intentionally do not extract the signed value and instead just verify the correctness.
+            //
+            // The reason is that common JavaScript frameworks can use the contents of the `XSRF-TOKEN` cookie as-is,
+            // without performing any processing on it, improving interoperability. Leveraging this JavaScript framework
+            // feature requires the author of the controller to check the value within the `X-XSRF-TOKEN` request header
+            // instead of the WoltLab Suite specific `t` parameter, though.
+            //
+            // The only reason we sign the cookie is that an XSS vulnerability or a rogue application on a subdomain
+            // is not able to create a valid `XSRF-TOKEN`, e.g. by setting the `XSRF-TOKEN` cookie to the static
+            // value `1234`, possibly allowing later exploitation.
+            if (!PACKAGE_ID || CryptoUtil::validateSignedString($_COOKIE['XSRF-TOKEN'])) {
+                $xsrfToken = $_COOKIE['XSRF-TOKEN'];
+            }
+        }
+
+        if (!$xsrfToken) {
+            if (PACKAGE_ID) {
+                $xsrfToken = CryptoUtil::createSignedString(\random_bytes(16));
+            } else {
+                $xsrfToken = Hex::encode(\random_bytes(16));
+            }
+
+            // We construct the cookie manually instead of using HeaderUtil::setCookie(), because:
+            // 1) We don't want the prefix. The `XSRF-TOKEN` cookie name is a standard name across applications
+            //    and it is supported by default in common JavaScript frameworks.
+            // 2) We want to set the SameSite=strict parameter.
+            // 3) We don't want the HttpOnly parameter.
+            $sameSite = $cookieDomain = '';
+
+            if (ApplicationHandler::getInstance()->isMultiDomainSetup()) {
+                // We need to specify the cookieDomain in a multi domain set-up, because
+                // otherwise no cookies are sent to subdomains.
+                $cookieDomain = HeaderUtil::getCookieDomain();
+                $cookieDomain = ($cookieDomain !== null ? '; domain=' . $cookieDomain : '');
+            } else {
+                // SameSite=strict is not supported in a multi domain set-up, because
+                // it breaks cross-application requests.
+                $sameSite = '; SameSite=strict';
+            }
+
+            \header(
+                'set-cookie: XSRF-TOKEN=' . \rawurlencode($xsrfToken) . '; path=/' . $cookieDomain . (RouteHandler::secureConnection() ? '; secure' : '') . $sameSite,
+                false
+            );
+        }
+
+        $this->xsrfToken = $xsrfToken;
+    }
+
+    /**
+     * Returns security token.
+     *
+     * @return  string
+     */
+    public function getSecurityToken()
+    {
+        if ($this->xsrfToken === null) {
+            $this->initSecurityToken();
+        }
+
+        return $this->xsrfToken;
+    }
+
+    /**
+     * Validates the given security token, returns false if
+     * given token is invalid.
+     *
+     * @param string $token
+     * @return  bool
+     */
+    public function checkSecurityToken($token)
+    {
+        // The output of CryptoUtil::createSignedString() is not url-safe. For compatibility
+        // reasons the SECURITY_TOKEN in URLs might not be encoded, turning the '+' into a space.
+        // Convert it back before comparing.
+        $token = \str_replace(' ', '+', $token);
+
+        return \hash_equals($this->getSecurityToken(), $token);
+    }
+
+    /**
+     * Registers a session variable.
+     *
+     * @param string $key
+     * @param mixed $value
+     */
+    public function register($key, $value)
+    {
+        $scope = $this->isACP ? 'acp' : 'frontend';
+
+        $this->variables[$scope][$key] = $value;
+        $this->variablesChanged = true;
+    }
+
+    /**
+     * Unsets a session variable.
+     *
+     * @param string $key
+     */
+    public function unregister($key)
+    {
+        $scope = $this->isACP ? 'acp' : 'frontend';
+
+        unset($this->variables[$scope][$key]);
+        $this->variablesChanged = true;
+    }
+
+    /**
+     * Returns the value of a session variable or `null` if the session
+     * variable does not exist.
+     *
+     * @param string $key
+     * @return  mixed
+     */
+    public function getVar($key)
+    {
+        $scope = $this->isACP ? 'acp' : 'frontend';
+
+        if (isset($this->variables[$scope][$key])) {
+            return $this->variables[$scope][$key];
+        }
+    }
+
+    /**
+     * Returns the user object of this session.
+     *
+     * @return  User    $user
+     */
+    public function getUser()
+    {
+        return $this->user;
+    }
+
+    /**
+     * Tries to read existing session identified by the given session id. Returns whether
+     * a session could be found.
+     */
+    protected function getExistingSession(string $sessionID): bool
+    {
+        $sql = "SELECT  *
+                FROM    wcf" . WCF_N . "_user_session
+                WHERE   sessionID = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $sessionID,
+        ]);
+        $row = $statement->fetchSingleRow();
+
+        if (!$row) {
+            return false;
+        }
+
+        // Check whether the session technically already expired.
+        $lifetime = ($row['userID'] ? self::USER_SESSION_LIFETIME : self::GUEST_SESSION_LIFETIME);
+        if ($row['lastActivityTime'] < (TIME_NOW - $lifetime)) {
+            return false;
+        }
+
+        $variables = @\unserialize($row['sessionVariables']);
+        // Check whether the session variables became corrupted.
+        if (!\is_array($variables)) {
+            return false;
+        }
+
+        $this->sessionID = $sessionID;
+        $this->user = new User($row['userID']);
+        $this->variables = $variables;
+
+        $sql = "UPDATE  wcf" . WCF_N . "_user_session
+                SET     ipAddress = ?,
+                        userAgent = ?,
+                        lastActivityTime = ?
+                WHERE   sessionID = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            UserUtil::getIpAddress(),
+            UserUtil::getUserAgent(),
+            TIME_NOW,
+            $this->sessionID,
+        ]);
+
+        if (!$this->isACP) {
+            // Fetch legacy session.
+            $condition = new PreparedStatementConditionBuilder();
+
+            if ($row['userID']) {
+                // The `userID IS NOT NULL` condition technically is redundant, but is added for
+                // clarity and consistency with the guest case below.
+                $condition->add('userID IS NOT NULL');
+                $condition->add('userID = ?', [$row['userID']]);
+            } else {
+                $condition->add('userID IS NULL');
+                $condition->add('(sessionID = ? OR spiderID = ?)', [
+                    $row['sessionID'],
+                    $this->getSpiderID(UserUtil::getUserAgent()),
+                ]);
+            }
+
+            $sql = "SELECT  *
+                    FROM    wcf" . WCF_N . "_session
+                    " . $condition;
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute($condition->getParameters());
+            $this->legacySession = $statement->fetchSingleObject(LegacySession::class);
+
+            if (!$this->legacySession) {
+                $this->legacySession = $this->createLegacySession();
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Creates a new session.
+     */
+    protected function create()
+    {
+        $this->sessionID = Hex::encode(\random_bytes(20));
+
+        $variables = [
+            'frontend' => [],
+            'acp' => [],
+        ];
+
+        // Create new session.
+        $sql = "INSERT INTO wcf" . WCF_N . "_user_session
+                            (sessionID, ipAddress, userAgent, creationTime, lastActivityTime, sessionVariables)
+                VALUES      (?, ?, ?, ?, ?, ?)";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            $this->sessionID,
+            UserUtil::getIpAddress(),
+            UserUtil::getUserAgent(),
+            TIME_NOW,
+            TIME_NOW,
+            \serialize($variables),
+        ]);
+
+        $this->variables = $variables;
+        $this->user = new User(null);
+        $this->firstVisit = true;
+
+        HeaderUtil::setCookie(
+            "user_session",
+            $this->getCookieValue()
+        );
+
+        // Maintain legacy session table for users online list.
+        $this->legacySession = null;
+
+        if (!$this->isACP) {
+            // Try to find an existing spider session. Order by lastActivityTime to maintain a
+            // stable selection in case duplicates exist for some reason.
+            $spiderID = $this->getSpiderID(UserUtil::getUserAgent());
+            if ($spiderID) {
+                $sql = "SELECT      *
+                        FROM        wcf" . WCF_N . "_session
+                        WHERE       spiderID = ?
+                                AND userID IS NULL
+                        ORDER BY    lastActivityTime DESC";
+                $statement = WCF::getDB()->prepareStatement($sql);
+                $statement->execute([$spiderID]);
+                $this->legacySession = $statement->fetchSingleObject(LegacySession::class);
+            }
+
+            if (!$this->legacySession) {
+                $this->legacySession = $this->createLegacySession();
+            }
+        }
+    }
+
+    private function createLegacySession(): LegacySession
+    {
+        $spiderID = null;
+        if (!$this->user->userID) {
+            $spiderID = $this->getSpiderID(UserUtil::getUserAgent());
+        }
+
+        // save session
+        $sessionData = [
+            'sessionID' => $this->sessionID,
+            'userID' => $this->user->userID,
+            'ipAddress' => UserUtil::getIpAddress(),
+            'userAgent' => UserUtil::getUserAgent(),
+            'lastActivityTime' => TIME_NOW,
+            'requestURI' => UserUtil::getRequestURI(),
+            'requestMethod' => !empty($_SERVER['REQUEST_METHOD']) ? \substr($_SERVER['REQUEST_METHOD'], 0, 7) : '',
+            'spiderID' => $spiderID,
+        ];
+
+        return SessionEditor::create($sessionData);
+    }
+
+    /**
+     * Returns the value of the permission with the given name.
+     *
+     * @param string $permission
+     * @return  mixed       permission value
+     */
+    public function getPermission($permission)
+    {
+        // check if a users only permission is checked for a guest and return
+        // false if that is the case
+        if (!$this->user->userID && \in_array($permission, $this->usersOnlyPermissions)) {
+            return false;
+        }
+
+        $this->loadGroupData();
+
+        if (!isset($this->groupData[$permission])) {
+            return false;
+        }
+
+        return $this->groupData[$permission];
+    }
+
+    /**
+     * Returns true if a permission was set to 'Never'. This is required to preserve
+     * compatibility, while preventing ACLs from overruling a 'Never' setting.
+     *
+     * @param string $permission
+     * @return      bool
+     */
+    public function getNeverPermission($permission)
+    {
+        $this->loadGroupData();
+
+        return isset($this->groupData['__never'][$permission]);
+    }
+
+    /**
+     * Checks if the active user has the given permissions and throws a
+     * PermissionDeniedException if that isn't the case.
+     *
+     * @param string[] $permissions list of permissions where each one must pass
+     * @throws  PermissionDeniedException
+     */
+    public function checkPermissions(array $permissions)
+    {
+        foreach ($permissions as $permission) {
+            if (!$this->getPermission($permission)) {
+                throw new PermissionDeniedException();
+            }
+        }
+    }
+
+    /**
+     * Loads group data from cache.
+     */
+    protected function loadGroupData()
+    {
+        if ($this->groupData !== null) {
+            return;
+        }
+
+        // work-around for setup process (package wcf does not exist yet)
+        if (!PACKAGE_ID) {
+            $sql = "SELECT  groupID
+                    FROM    wcf" . WCF_N . "_user_to_group
+                    WHERE   userID = ?";
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute([$this->user->userID]);
+            $groupIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
+        } else {
+            $groupIDs = $this->user->getGroupIDs();
+        }
+
+        // get group data from cache
+        $this->groupData = UserGroupPermissionCacheBuilder::getInstance()->getData($groupIDs);
+        if (isset($this->groupData['groupIDs']) && $this->groupData['groupIDs'] != $groupIDs) {
+            $this->groupData = [];
+        }
+    }
+
+    /**
+     * Returns language ids for active user.
+     *
+     * @return  int[]
+     */
+    public function getLanguageIDs()
+    {
+        $this->loadLanguageIDs();
+
+        return $this->languageIDs;
+    }
+
+    /**
+     * Loads language ids for active user.
+     */
+    protected function loadLanguageIDs()
+    {
+        if ($this->languageIDs !== null) {
+            return;
+        }
+
+        $this->languageIDs = [];
+
+        if (!$this->user->userID) {
+            return;
+        }
+
+        // work-around for setup process (package wcf does not exist yet)
+        if (!PACKAGE_ID) {
+            $sql = "SELECT  languageID
+                    FROM    wcf" . WCF_N . "_user_to_language
+                    WHERE   userID = ?";
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute([$this->user->userID]);
+            $this->languageIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
+        } else {
+            $this->languageIDs = $this->user->getLanguageIDs();
+        }
+    }
+
+    /**
+     * If multi-factor authentication is enabled for the given user then
+     * - the userID will be stored in the session variables, and
+     * - `true` is returned.
+     * Otherwise,
+     * - `changeUser()` will be called, and
+     * - `false` is returned.
+     *
+     * If `true` is returned you should perform a redirect to `MultifactorAuthenticationForm`.
+     *
+     * @since 5.4
+     */
+    public function changeUserAfterMultifactorAuthentication(User $user): bool
+    {
+        if ($user->multifactorActive) {
+            $this->register(self::CHANGE_USER_AFTER_MULTIFACTOR_KEY, [
+                'userId' => $user->userID,
+                'expires' => TIME_NOW + self::PENDING_USER_LIFETIME,
+            ]);
+            $this->setLanguageID($user->languageID);
+
+            return true;
+        } else {
+            $this->changeUser($user);
+
+            return false;
+        }
+    }
+
+    /**
+     * Applies the pending user change, calling `changeUser()` for the user returned
+     * by `getPendingUserChange()`.
+     *
+     * As a safety check you must provide the `$expectedUser` as a parameter, it must match the
+     * data stored within the session.
+     *
+     * @throws \RuntimeException If the `$expectedUser` does not match.
+     * @throws \BadMethodCallException If `getPendingUserChange()` returns `null`.
+     * @see SessionHandler::getPendingUserChange()
+     * @since 5.4
+     */
+    public function applyPendingUserChange(User $expectedUser): void
+    {
+        $user = $this->getPendingUserChange();
+        $this->clearPendingUserChange();
+
+        if ($user->userID !== $expectedUser->userID) {
+            throw new \RuntimeException('Mismatching expectedUser.');
+        }
+
+        if (!$user) {
+            throw new \BadMethodCallException('No pending user change.');
+        }
+
+        $this->changeUser($user);
+    }
+
+    /**
+     * Returns the pending user change initiated by `changeUserAfterMultifactorAuthentication()`.
+     *
+     * @see SessionHandler::changeUserAfterMultifactorAuthentication()
+     * @since 5.4
+     */
+    public function getPendingUserChange(): ?User
+    {
+        $data = $this->getVar(self::CHANGE_USER_AFTER_MULTIFACTOR_KEY);
+        if (!$data) {
+            return null;
+        }
+
+        $userId = $data['userId'];
+        $expires = $data['expires'];
+
+        if ($expires < TIME_NOW) {
+            return null;
+        }
+
+        $user = new User($userId);
+
+        if (!$user->userID) {
+            return null;
+        }
+
+        return $user;
+    }
+
+    /**
+     * Clears a pending user change, reverses the effects of `changeUserAfterMultifactorAuthentication()`.
+     *
+     * @see SessionHandler::changeUserAfterMultifactorAuthentication()
+     * @since 5.4
+     */
+    public function clearPendingUserChange(): void
+    {
+        $this->unregister(self::CHANGE_USER_AFTER_MULTIFACTOR_KEY);
+    }
+
+    /**
+     * Stores a new user object in this session, e.g. a user was guest because not
+     * logged in, after the login his old session is used to store his full data.
+     *
+     * @param User $user
+     * @param bool $hideSession if true, database won't be updated
+     */
+    public function changeUser(User $user, $hideSession = false)
+    {
+        $eventParameters = ['user' => $user, 'hideSession' => $hideSession];
+
+        EventHandler::getInstance()->fireAction($this, 'beforeChangeUser', $eventParameters);
+
+        $user = $eventParameters['user'];
+        $hideSession = $eventParameters['hideSession'];
+
+        // skip changeUserVirtual, if session will not be persistent anyway
+        if (!$hideSession) {
+            $this->changeUserVirtual($user);
+        }
+
+        // update user reference
+        $this->user = $user;
+
+        // reset caches
+        $this->groupData = null;
+        $this->languageIDs = null;
+        $this->languageID = $this->user->languageID;
+        $this->styleID = $this->user->styleID;
+
+        // change language
+        WCF::setLanguage($this->languageID ?: 0);
+
+        // in some cases the language id can be stuck in the session variables
+        $this->unregister('languageID');
+
+        EventHandler::getInstance()->fireAction($this, 'afterChangeUser');
+    }
+
+    /**
+     * Changes the user stored in the session.
+     *
+     * @param User $user
+     * @throws  DatabaseException
+     */
+    protected function changeUserVirtual(User $user)
+    {
+        // We must delete the old session to not carry over any state across different users.
+        $this->delete();
+
+        // If the target user is a registered user ...
+        if ($user->userID) {
+            // ... we create a new session with a new session ID ...
+            $this->create();
+
+            // ... delete the newly created legacy session ...
+            $sql = "DELETE FROM wcf" . WCF_N . "_session
+                    WHERE       sessionID = ?";
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute([$this->sessionID]);
+
+            // ... perform the login ...
+            $sql = "UPDATE  wcf" . WCF_N . "_user_session
+                    SET     userID = ?
+                    WHERE   sessionID = ?";
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute([
+                $user->userID,
+                $this->sessionID,
+            ]);
+
+            // ... delete any user sessions exceeding the limit ...
+            $sql = "SELECT  all_sessions.sessionID
+                    FROM    wcf" . WCF_N . "_user_session all_sessions
+                    LEFT JOIN (
+                        SELECT      sessionID
+                        FROM        wcf" . WCF_N . "_user_session
+                        WHERE       userID = ?
+                        ORDER BY    lastActivityTime DESC
+                        LIMIT       " . self::USER_SESSION_LIMIT . "
+                    ) newest_sessions
+                    ON      newest_sessions.sessionID = all_sessions.sessionID
+                    WHERE   all_sessions.userID = ?
+                        AND newest_sessions.sessionID IS NULL";
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute([
+                $user->userID,
+                $user->userID,
+            ]);
+            foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $sessionID) {
+                $this->deleteUserSession($sessionID);
+            }
+
+            // ... and reload the session with the updated information.
+            $hasSession = $this->getExistingSession($this->sessionID);
+
+            if (!$hasSession) {
+                throw new \LogicException('Unreachable');
+            }
+        }
+    }
+
+    /**
+     * Checks whether the user needs to authenticate themselves once again
+     * to access a security critical area.
+     *
+     * If `true` is returned you should perform a redirect to `ReAuthenticationForm`,
+     * otherwise the user is sufficiently authenticated and may proceed.
+     *
+     * @throws \BadMethodCallException If the current user is a guest.
+     * @since 5.4
+     */
+    public function needsReauthentication(): bool
+    {
+        if (!$this->getUser()->userID) {
+            throw new \BadMethodCallException('The current user is a guest.');
+        }
+
+        // Reauthentication for third party authentication is not supported.
+        if ($this->getUser()->authData) {
+            return false;
+        }
+
+        $data = $this->getVar(self::REAUTHENTICATION_KEY);
+
+        // Request a new authentication if no stored information is available.
+        if (!$data) {
+            return true;
+        }
+
+        $lastAuthentication = $data['lastAuthentication'];
+        $lastCheck = $data['lastCheck'];
+
+        // Request a new authentication if the hard limit since the last authentication
+        // is exceeded.
+        if ($lastAuthentication < (TIME_NOW - self::REAUTHENTICATION_HARD_LIMIT)) {
+            return true;
+        }
+
+        $softLimit = self::REAUTHENTICATION_SOFT_LIMIT;
+        if ($this->isACP) {
+            $softLimit = self::REAUTHENTICATION_SOFT_LIMIT_ACP;
+
+            // If both the debug mode and the developer tools are enabled the
+            // reauthentication soft limit within the ACP matches the hard limit.
+            //
+            // This allows for a continous access to the ACP and specifically the
+            // developer tools within a single workday without needing to re-login
+            // just because one spent 15 minutes within the IDE.
+            if (ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS) {
+                $softLimit = self::REAUTHENTICATION_HARD_LIMIT;
+            }
+        }
+
+        // Request a new authentication if the soft limit since the last authentication
+        // is exceeded ...
+        if ($lastAuthentication < (TIME_NOW - $softLimit)) {
+            // ... and the grace period since the last check is also exceeded.
+            if ($lastCheck < (TIME_NOW - self::REAUTHENTICATION_GRACE_PERIOD)) {
+                return true;
+            }
+        }
+
+        // If we reach this point we determined that a new authentication is not necessary.
+        \assert(
+            ($lastAuthentication >= TIME_NOW - $softLimit)
+            || ($lastAuthentication >= TIME_NOW - self::REAUTHENTICATION_HARD_LIMIT
+                && $lastCheck >= TIME_NOW - self::REAUTHENTICATION_GRACE_PERIOD)
+        );
+
+        // Update the lastCheck timestamp to make sure that the grace period works properly.
+        //
+        // The grace period allows the user to complete their action if the soft limit
+        // expires between loading a form and actually submitting that form, provided that
+        // the user does not take longer than the grace period to fill in the form.
+        $data['lastCheck'] = TIME_NOW;
+        $this->register(self::REAUTHENTICATION_KEY, $data);
+
+        return false;
+    }
+
+    /**
+     * Registers that the user performed reauthentication successfully.
+     *
+     * This method should be considered to be semi-public and is intended to be used
+     * by `ReAuthenticationForm` only.
+     *
+     * @throws \BadMethodCallException If the current user is a guest.
+     * @see SessionHandler::needsReauthentication()
+     * @since 5.4
+     */
+    public function registerReauthentication(): void
+    {
+        if (!$this->getUser()->userID) {
+            throw new \BadMethodCallException('The current user is a guest.');
+        }
+
+        $this->register(self::REAUTHENTICATION_KEY, [
+            'lastAuthentication' => TIME_NOW,
+            'lastCheck' => TIME_NOW,
+        ]);
+    }
+
+    /**
+     * Clears that the user performed reauthentication successfully.
+     *
+     * After this method is called `needsReauthentication()` will return true until
+     * `registerReauthentication()` is called again.
+     *
+     * @throws \BadMethodCallException If the current user is a guest.
+     * @see SessionHandler::needsReauthentication()
+     * @see SessionHandler::registerReauthentication()
+     * @since 5.4
+     */
+    public function clearReauthentication(): void
+    {
+        if (!$this->getUser()->userID) {
+            throw new \BadMethodCallException('The current user is a guest.');
+        }
+
+        $this->unregister(self::REAUTHENTICATION_KEY);
+    }
+
+    /**
+     * Updates user session on shutdown.
+     */
+    public function update()
+    {
+        if ($this->doNotUpdate) {
+            return;
+        }
+
+        if ($this->variablesChanged) {
+            $sql = "UPDATE  wcf" . WCF_N . "_user_session
+                    SET     sessionVariables = ?
+                    WHERE   sessionID = ?";
+            $statement = WCF::getDB()->prepareStatement($sql);
+            $statement->execute([
+                \serialize($this->variables),
+                $this->sessionID,
+            ]);
+
+            // Reset the flag, because the variables are no longer dirty.
+            $this->variablesChanged = false;
+        }
+
+        $data = [
+            'ipAddress' => UserUtil::getIpAddress(),
+            'userAgent' => $this->userAgent,
+            'requestURI' => $this->requestURI,
+            'requestMethod' => $this->requestMethod,
+            'lastActivityTime' => TIME_NOW,
+            'userID' => $this->user->userID,
+            'sessionID' => $this->sessionID,
+        ];
+        if (!\class_exists('wcf\system\CLIWCF', false) && !$this->disableTracking) {
+            $pageLocations = PageLocationManager::getInstance()->getLocations();
+            if (isset($pageLocations[0])) {
+                $data['pageID'] = $pageLocations[0]['pageID'];
+                $data['pageObjectID'] = ($pageLocations[0]['pageObjectID'] ?: null);
+                $data['parentPageID'] = null;
+                $data['parentPageObjectID'] = null;
+
+                for ($i = 1, $length = \count($pageLocations); $i < $length; $i++) {
+                    if (!empty($pageLocations[$i]['useAsParentLocation'])) {
+                        $data['parentPageID'] = $pageLocations[$i]['pageID'];
+                        $data['parentPageObjectID'] = ($pageLocations[$i]['pageObjectID'] ?: null);
+                        break;
+                    }
+                }
+            }
+        }
+
+        if ($this->legacySession) {
+            $sessionEditor = new SessionEditor($this->legacySession);
+            $sessionEditor->update($data);
+        }
+    }
+
+    /**
+     * @deprecated 5.4 - This method is a noop. The lastActivityTime is always updated immediately after loading.
+     */
+    public function keepAlive()
+    {
+    }
+
+    /**
+     * Deletes this session and its related data.
+     */
+    public function delete()
+    {
+        // clear storage
+        if ($this->user->userID) {
+            self::resetSessions([$this->user->userID]);
+
+            // update last activity time
+            $editor = new UserEditor($this->user);
+            $editor->update(['lastActivityTime' => TIME_NOW]);
+        }
+
+        $this->deleteUserSession($this->sessionID);
+    }
+
+    /**
+     * Prunes expired sessions.
+     */
+    public function prune()
+    {
+        $sql = "DELETE FROM wcf" . WCF_N . "_user_session
+                WHERE       (lastActivityTime < ? AND userID IS NULL)
+                         OR (lastActivityTime < ? AND userID IS NOT NULL)";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            TIME_NOW - self::GUEST_SESSION_LIFETIME,
+            TIME_NOW - self::USER_SESSION_LIFETIME,
+        ]);
+
+        // Legacy sessions live 120 minutes, they will be re-created on demand.
+        $sql = "DELETE FROM wcf" . WCF_N . "_session
+                WHERE       lastActivityTime < ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([
+            TIME_NOW - (3600 * 2),
+        ]);
+    }
+
+    /**
+     * Deletes this session if:
+     * - it is newly created in this request, and
+     * - it belongs to a guest.
+     *
+     * This method is useful if you have controllers that are likely to be
+     * accessed by a user agent that is not going to re-use sessions (e.g.
+     * curl in a cronjob). It immediately remove the session that was created
+     * just for that request and that is not going to be used ever again.
+     *
+     * @since 5.2
+     */
+    public function deleteIfNew()
+    {
+        if ($this->isFirstVisit() && !$this->getUser()->userID) {
+            $this->delete();
+        }
+    }
+
+    /**
+     * Returns currently active language id.
+     *
+     * @return  int
+     */
+    public function getLanguageID()
+    {
+        return $this->languageID;
+    }
+
+    /**
+     * Sets the currently active language id.
+     *
+     * @param int $languageID
+     */
+    public function setLanguageID($languageID)
+    {
+        $this->languageID = $languageID;
+        $this->register('languageID', $this->languageID);
+    }
+
+    /**
+     * Returns currently active style id.
+     *
+     * @return  int
+     */
+    public function getStyleID()
+    {
+        return $this->styleID;
+    }
+
+    /**
+     * Sets the currently active style id.
+     *
+     * @param int $styleID
+     */
+    public function setStyleID($styleID)
+    {
+        $this->styleID = $styleID;
+        $this->register('styleID', $this->styleID);
+    }
+
+    /**
+     * Resets session-specific storage data.
+     *
+     * @param int[] $userIDs
+     */
+    public static function resetSessions(array $userIDs = [])
+    {
+        if (!empty($userIDs)) {
+            UserStorageHandler::getInstance()->reset($userIDs, 'groupIDs');
+            UserStorageHandler::getInstance()->reset($userIDs, 'languageIDs');
+        } else {
+            UserStorageHandler::getInstance()->resetAll('groupIDs');
+            UserStorageHandler::getInstance()->resetAll('languageIDs');
+        }
+    }
+
+    /**
+     * Returns the spider id for given user agent.
+     */
+    protected function getSpiderID(string $userAgent): ?int
+    {
+        $spiderList = SpiderCacheBuilder::getInstance()->getData();
+        $userAgent = \strtolower($userAgent);
+
+        foreach ($spiderList as $spider) {
+            if (\strpos($userAgent, $spider->spiderIdentifier) !== false) {
+                return \intval($spider->spiderID);
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Returns true if this is a new session.
+     *
+     * @return  bool
+     */
+    public function isFirstVisit()
+    {
+        return $this->firstVisit;
+    }
+
+    /**
+     * Returns all sessions for a specific user.
+     *
+     * @return      Session[]
+     * @throws      \InvalidArgumentException if the given user is a guest.
+     * @since       5.4
+     */
+    public function getUserSessions(User $user): array
+    {
+        if (!$user->userID) {
+            throw new \InvalidArgumentException("The given user is a guest.");
+        }
+
+        $sql = "SELECT  *
+                FROM    wcf" . WCF_N . "_user_session
+                WHERE   userID = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([$user->userID]);
+
+        $sessions = [];
+        while ($row = $statement->fetchArray()) {
+            $sessions[] = new Session($row);
+        }
+
+        return $sessions;
+    }
+
+    /**
+     * Deletes the sessions for a specific user, except the session with the given session id.
+     *
+     * If the given session id is `null` or unknown, all sessions of the user will be deleted.
+     *
+     * @throws      \InvalidArgumentException if the given user is a guest.
+     * @since       5.4
+     */
+    public function deleteUserSessionsExcept(User $user, ?string $sessionID = null): void
+    {
+        if (!$user->userID) {
+            throw new \InvalidArgumentException("The given user is a guest.");
+        }
+
+        $conditionBuilder = new PreparedStatementConditionBuilder();
+        $conditionBuilder->add('userID = ?', [$user->userID]);
+
+        if ($sessionID !== null) {
+            $conditionBuilder->add('sessionID <> ?', [$sessionID]);
+        }
+
+        $sql = "DELETE FROM wcf" . WCF_N . "_user_session
+                " . $conditionBuilder;
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute($conditionBuilder->getParameters());
+
+        // Delete legacy session.
+        $sql = "DELETE FROM wcf" . WCF_N . "_session
+            " . $conditionBuilder;
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute($conditionBuilder->getParameters());
+    }
+
+    /**
+     * Deletes a session with the given session ID.
+     *
+     * @since       5.4
+     */
+    public function deleteUserSession(string $sessionID): void
+    {
+        $sql = "DELETE FROM wcf" . WCF_N . "_user_session
+                WHERE       sessionID = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([$sessionID]);
+
+        // Delete legacy session.
+        $sql = "DELETE FROM wcf" . WCF_N . "_session
+                WHERE       sessionID = ?";
+        $statement = WCF::getDB()->prepareStatement($sql);
+        $statement->execute([$sessionID]);
+    }
 }