Commit 733353c1 authored by Benni Mack's avatar Benni Mack
Browse files

[!!!][FEATURE] Refactored Session Handling

The AbstractUserAuthentication class handles way too much
of what it should know / do.

For this reason, a new UserSession object which contains
basic information needed for everything belonging to a non-fixated
session, a fixated anonymous session, if a session was evelated,
or if a session has expired, is kept in there.
The "SessionManager" should not be used anymore publically
but slowly dissolve into a SessionBackendManager.

Design goals:
* UserAuth object should not know about session backends
* UserAuth should not store sessionData etc. directly in its own object
* Decouple UserSession info from any properties of UserAuth
* A UserSessionManager deals with the creation and validation of the UserSession objects. No Session Objects can be created etc outside
of this class to maintain persistability
* UserSessionManager also encapsulates ipLocking and the responsible SessionBackend

Final goals to be tackled later:
* Build a user session object from the request object, and not within the UserAuth object
* Session Handling can be accessed outside of UserAuth
* Cookie Handling and Session Handling are separated from UserAuth
* Load Session information from PSR-7 request instead of $_COOKIE

Resolves: #93023
Releases: master
Change-Id: Ia2d8244e433d0f6adf220d443b2c0947f251b5e9
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/66935


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent f16b4787
......@@ -52,7 +52,7 @@ class UserToolbarItem implements ToolbarItemInterface
$view = $this->getFluidTemplateObject('UserToolbarItem.html');
$view->assignMultiple([
'currentUser' => $backendUser->user,
'switchUserMode' => $backendUser->user['ses_backuserid'],
'switchUserMode' => (int)$backendUser->getOriginalUserIdWhenInSwitchUserMode(),
]);
return $view->render();
}
......@@ -73,7 +73,7 @@ class UserToolbarItem implements ToolbarItemInterface
$mostRecentUsers = [];
if (ExtensionManagementUtility::isLoaded('beuser')
&& $backendUser->isAdmin()
&& (int)$backendUser->user['ses_backuserid'] === 0
&& !$backendUser->getOriginalUserIdWhenInSwitchUserMode()
&& isset($backendUser->uc['recentSwitchedToUsers'])
&& is_array($backendUser->uc['recentSwitchedToUsers'])
) {
......@@ -115,7 +115,7 @@ class UserToolbarItem implements ToolbarItemInterface
$view->assignMultiple([
'modules' => $backendModuleRepository->findByModuleName('user')->getChildren(),
'logoutUrl' => (string)$uriBuilder->buildUriFromRoute('logout'),
'switchUserMode' => $this->getBackendUser()->user['ses_backuserid'],
'switchUserMode' => (int)$this->getBackendUser()->getOriginalUserIdWhenInSwitchUserMode(),
'recentUsers' => $mostRecentUsers,
]);
return $view->render();
......@@ -131,7 +131,7 @@ class UserToolbarItem implements ToolbarItemInterface
$result = [
'class' => 'toolbar-item-user'
];
if ($this->getBackendUser()->user['ses_backuserid']) {
if ($this->getBackendUser()->getOriginalUserIdWhenInSwitchUserMode()) {
$result['class'] .= ' su-user';
}
return $result;
......
......@@ -21,6 +21,7 @@ use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Session\UserSessionManager;
/**
* This is the ajax handler for backend login after timeout.
......@@ -106,12 +107,10 @@ class AjaxLoginController
} elseif (!isset($backendUser->user['uid'])) {
$session['timed_out'] = true;
} else {
$backendUser->fetchUserSession(true);
$ses_tstamp = $backendUser->user['ses_tstamp'];
$timeout = $backendUser->sessionTimeout;
$sessionManager = UserSessionManager::create('BE');
// If 120 seconds from now is later than the session timeout, we need to show the refresh dialog.
// 120 is somewhat arbitrary to allow for a little room during the countdown and load times, etc.
$session['will_time_out'] = $GLOBALS['EXEC_TIME'] >= $ses_tstamp + $timeout - 120;
$session['will_time_out'] = $sessionManager->willExpire($backendUser->getSession(), 120);
}
return new JsonResponse(['login' => $session]);
}
......
......@@ -26,6 +26,7 @@ use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\ImmediateResponseException;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Session\UserSessionManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
......@@ -85,6 +86,8 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
// Re-setting the user and take the workspace from the user object now
$this->setBackendUserAspect($GLOBALS['BE_USER']);
$response = $handler->handle($request);
$this->sessionGarbageCollection();
} catch (ImmediateResponseException $e) {
$response = $this->enrichResponseWithHeadersAndCookieInformation(
$e->getResponse(),
......@@ -123,6 +126,14 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
return $response;
}
/**
* Garbage collection for be_sessions (with a probability)
*/
protected function sessionGarbageCollection(): void
{
UserSessionManager::create('BE')->collectGarbage();
}
/**
* Check if the user is required for the request.
* If we're trying to do a login or an ajax login, don't require a user.
......
......@@ -67,9 +67,14 @@ class EmailLoginNotification
public function emailAtLogin(array $parameters, BackendUserAuthentication $currentUser): void
{
$user = $parameters['user'];
$genericLoginWarning = $this->warningMode > 0 && !empty($this->warningEmailRecipient);
$userLoginNotification = ($currentUser->uc['emailMeAtLogin'] ?? null) && GeneralUtility::validEmail($user['email']);
if (!$genericLoginWarning && !$userLoginNotification) {
return;
}
$this->request = $parameters['request'] ?? $GLOBALS['TYPO3_REQUEST'] ?? ServerRequestFactory::fromGlobals();
if ($this->warningMode > 0 && !empty($this->warningEmailRecipient)) {
if ($genericLoginWarning) {
$prefix = $currentUser->isAdmin() ? '[AdminLoginWarning]' : '[LoginWarning]';
if ($this->warningMode & 1) {
// First bit: Send warning email on any login
......@@ -80,7 +85,7 @@ class EmailLoginNotification
}
}
// Trigger an email to the current BE user, if this has been enabled in the user configuration
if (($currentUser->uc['emailMeAtLogin'] ?? null) && GeneralUtility::validEmail($user['email'])) {
if ($userLoginNotification) {
$this->sendEmail($user['email'], $currentUser);
}
}
......
......@@ -105,7 +105,6 @@ class ConditionMatcherTest extends FunctionalTestCase
*/
public function loginUserConditionDoesNotMatchSingleLoggedInUser(): void
{
$GLOBALS['BE_USER']->user['uid'] = 13;
$subject = $this->getConditionMatcher();
self::assertFalse($subject->match('[loginUser(999)]'));
self::assertFalse($subject->match('[loginUser("999")]'));
......
......@@ -360,7 +360,7 @@ class BackendUserController extends ActionController
$this->backendUserSessionRepository->switchToUser($this->getBackendUserAuthentication(), (int)$targetUser['uid']);
$event = new SwitchUserEvent(
$this->getBackendUserAuthentication()->getSessionId(),
$this->getBackendUserAuthentication()->getSession()->getIdentifier(),
$targetUser,
(array)$this->getBackendUserAuthentication()->user
);
......
......@@ -89,37 +89,38 @@ class BackendUserSessionRepository
*/
public function switchBackToOriginalUser(AbstractUserAuthentication $userObject): void
{
$this->sessionBackend->update(
$userObject->getSessionId(),
[
'ses_userid' => $userObject->user['ses_backuserid'],
'ses_backuserid' => 0
]
);
$sessionObject = $userObject->getSession();
$originalUser = (int)$sessionObject->get('backuserid');
$sessionObject->set('backuserid', null);
$sessionRecord = $sessionObject->toArray();
$sessionRecord['ses_userid'] = $originalUser;
$this->sessionBackend->update($sessionObject->getIdentifier(), $sessionRecord);
// We must regenerate the internal session so the new ses_userid is present in the userObject
$userObject->enforceNewSessionId();
}
/**
* Update current session to move to the target user. This is done
* by setting the target user id as ses_userid and storing the current
* user in ses_backuserid to restore the session record later on.
* user in backuserid to restore the session record later on.
*
* @param AbstractUserAuthentication $userObject
* @param int $targetUserId
*/
public function switchToUser(AbstractUserAuthentication $userObject, int $targetUserId): void
{
$this->sessionBackend->update(
$userObject->getSessionId(),
[
'ses_userid' => (int)$targetUserId,
'ses_backuserid' => (int)$userObject->user['uid']
]
);
$sessionObject = $userObject->getSession();
$sessionObject->set('backuserid', (int)$userObject->user['uid']);
$sessionRecord = $sessionObject->toArray();
$sessionRecord['ses_userid'] = $targetUserId;
$this->sessionBackend->update($sessionObject->getIdentifier(), $sessionRecord);
// We must regenerate the internal session so the new ses_userid is present in the userObject
$userObject->enforceNewSessionId();
}
public function getPersistedSessionIdentifier(AbstractUserAuthentication $userObject): string
{
$currentSessionId = $userObject->getSessionId();
$currentSessionId = $userObject->getSession()->getIdentifier();
if ($this->sessionBackend instanceof HashableSessionBackendInterface) {
$currentSessionId = $this->sessionBackend->hash($currentSessionId);
}
......
......@@ -61,6 +61,6 @@ class SwitchBackUserHook
return ($authentication instanceof BackendUserAuthentication)
&& is_array($authentication->user)
&& (int)$authentication->user['uid'] > 0
&& (int)$authentication->user['ses_backuserid'] > 0;
&& $authentication->getOriginalUserIdWhenInSwitchUserMode();
}
}
......@@ -60,7 +60,7 @@ class SwitchUserViewHelper extends AbstractViewHelper
{
$backendUser = $arguments['backendUser'];
$iconFactory = GeneralUtility::makeInstance(IconFactory::class);
if ($backendUser->getUid() == $GLOBALS['BE_USER']->user['uid'] || !$backendUser->isActive() || $GLOBALS['BE_USER']->user['ses_backuserid']) {
if ($backendUser->getUid() == $GLOBALS['BE_USER']->user['uid'] || !$backendUser->isActive() || $GLOBALS['BE_USER']->getOriginalUserIdWhenInSwitchUserMode()) {
return '<span class="btn btn-default disabled">' . $iconFactory->getIcon('empty-empty', Icon::SIZE_SMALL)->render() . '</span>';
}
$title = LocalizationUtility::translate('switchBackMode', 'beuser') ?? '';
......
......@@ -248,18 +248,6 @@ class BackendUserAuthentication extends AbstractUserAuthentication
*/
public $writeAttemptLog = true;
/**
* Session timeout (on the server), defaults to 8 hours for backend user
*
* If >0: session-timeout in seconds.
* If <=0: Instant logout after login.
* The value must be at least 180 to avoid side effects.
*
* @var int
* @internal should only be used from within TYPO3 Core
*/
public $sessionTimeout = 28800;
/**
* @var int
* @internal should only be used from within TYPO3 Core
......@@ -306,7 +294,6 @@ class BackendUserAuthentication extends AbstractUserAuthentication
public function __construct()
{
$this->name = self::getCookieName();
$this->sessionTimeout = (int)$GLOBALS['TYPO3_CONF_VARS']['BE']['sessionTimeout'];
parent::__construct();
}
......@@ -484,7 +471,7 @@ class BackendUserAuthentication extends AbstractUserAuthentication
return false;
}
if ((int)$GLOBALS['BE_USER']->user['ses_backuserid'] !== 0) {
if ($GLOBALS['BE_USER']->getOriginalUserIdWhenInSwitchUserMode()) {
return false;
}
if (Environment::getContext()->isDevelopment()) {
......@@ -2265,11 +2252,11 @@ TCAdefaults.sys_note.email = ' . $this->user['email'];
$userId = $this->user['uid'];
}
if (!empty($this->user['ses_backuserid'])) {
if ($backuserid = $this->getOriginalUserIdWhenInSwitchUserMode()) {
if (empty($data)) {
$data = [];
}
$data['originalUser'] = $this->user['ses_backuserid'];
$data['originalUser'] = $backuserid;
}
$fields = [
......@@ -2465,15 +2452,14 @@ TCAdefaults.sys_note.email = ' . $this->user['email'];
// Backend user is allowed if adminOnly is not set or user is an admin:
if (!$adminOnlyMode || $this->isAdmin()) {
$isUserAllowedToLogin = true;
} elseif ($this->user['ses_backuserid']) {
$backendUserId = (int)$this->user['ses_backuserid'];
} elseif ($backUserId = $this->getOriginalUserIdWhenInSwitchUserMode()) {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
$isUserAllowedToLogin = (bool)$queryBuilder->count('uid')
->from('be_users')
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($backendUserId, \PDO::PARAM_INT)
$queryBuilder->createNamedParameter($backUserId, \PDO::PARAM_INT)
),
$queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(1, \PDO::PARAM_INT))
)
......@@ -2520,4 +2506,17 @@ TCAdefaults.sys_note.email = ' . $this->user['email'];
);
}
}
/**
* Returns the uid of the backend user to return to.
* This is set when the current session is a "switch-user" session.
*
* @return int|null The user id
* @internal should only be used from within TYPO3 Core
*/
public function getOriginalUserIdWhenInSwitchUserMode(): ?int
{
$originalUserId = $this->getSessionData('backuserid');
return $originalUserId ? (int)$originalUserId : null;
}
}
......@@ -6998,7 +6998,7 @@ class DataHandler implements LoggerAwareInterface
RecordHistoryStore::class,
RecordHistoryStore::USER_BACKEND,
$this->BE_USER->user['uid'],
$this->BE_USER->user['ses_backuserid'] ?? null,
(int)$this->BE_USER->getOriginalUserIdWhenInSwitchUserMode(),
$GLOBALS['EXEC_TIME'],
$this->BE_USER->workspace
);
......
......@@ -120,8 +120,8 @@ abstract class AbstractExceptionHandler implements ExceptionHandlerInterface, Si
if (isset($backendUser->workspace)) {
$workspace = $backendUser->workspace;
}
if (!empty($backendUser->user['ses_backuserid'])) {
$data['originalUser'] = $backendUser->user['ses_backuserid'];
if ($backUserId = $backendUser->getOriginalUserIdWhenInSwitchUserMode()) {
$data['originalUser'] = $backUserId;
}
}
......
......@@ -220,8 +220,8 @@ class ErrorHandler implements ErrorHandlerInterface, LoggerAwareInterface
if (isset($backendUser->workspace)) {
$workspace = $backendUser->workspace;
}
if (!empty($backendUser->user['ses_backuserid'])) {
$data['originalUser'] = $backendUser->user['ses_backuserid'];
if ($backUserId = $backendUser->getOriginalUserIdWhenInSwitchUserMode()) {
$data['originalUser'] = $backUserId;
}
}
......
......@@ -30,7 +30,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* Class DatabaseSessionBackend
*
* This session backend requires the 'table' configuration option. If the backend is used to holds non-authenticated
* sessions (default in frontend application), the 'ses_anonymous' configuration option must be set to true.
* sessions (default in frontend application), the 'ses_userid' configuration option must be set to `0`.
*/
class DatabaseSessionBackend implements SessionBackendInterface, HashableSessionBackendInterface
{
......@@ -40,7 +40,7 @@ class DatabaseSessionBackend implements SessionBackendInterface, HashableSession
protected $configuration = [];
/**
* @var bool Indicates whether the sessions table has the ses_anonymous column
* @var bool Indicates whether the ses_userid is set to `0` in the sessions table
*/
protected $hasAnonymousSessions = false;
......@@ -225,14 +225,14 @@ class DatabaseSessionBackend implements SessionBackendInterface, HashableSession
$query->delete($this->configuration['table'])
->where($query->expr()->lt('ses_tstamp', (int)($GLOBALS['EXEC_TIME'] - (int)$maximumLifetime)))
->andWhere($this->hasAnonymousSessions ? $query->expr()->eq('ses_anonymous', 0) : ' 1 = 1');
->andWhere($this->hasAnonymousSessions ? $query->expr()->neq('ses_userid', 0) : ' 1 = 1');
$query->execute();
if ($maximumAnonymousLifetime > 0 && $this->hasAnonymousSessions) {
$query = $this->getQueryBuilder();
$query->delete($this->configuration['table'])
->where($query->expr()->lt('ses_tstamp', (int)($GLOBALS['EXEC_TIME'] - (int)$maximumAnonymousLifetime)))
->andWhere($query->expr()->eq('ses_anonymous', 1));
->andWhere($query->expr()->eq('ses_userid', 0));
$query->execute();
}
}
......
......@@ -244,7 +244,7 @@ class RedisSessionBackend implements SessionBackendInterface, HashableSessionBac
public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0)
{
foreach ($this->getAll() as $sessionRecord) {
if ($sessionRecord['ses_anonymous']) {
if (!($sessionRecord['ses_userid'] ?? false)) {
if ($maximumAnonymousLifetime > 0 && ($sessionRecord['ses_tstamp'] + $maximumAnonymousLifetime) < $GLOBALS['EXEC_TIME']) {
$this->redis->del($this->getSessionKeyName($sessionRecord['ses_id']));
}
......
......@@ -79,7 +79,7 @@ class SessionManager implements SingletonInterface
$hashedSessionToRenew = '';
// Prevent destroying the session of the current user session, but renew session id
if ($userAuthentication !== null && (int)$userAuthentication->user['uid'] === $userId) {
$sessionToRenew = $userAuthentication->getSessionId();
$sessionToRenew = $userAuthentication->getSession()->getIdentifier();
}
if ($sessionToRenew !== '' && $backend instanceof HashableSessionBackendInterface) {
$hashedSessionToRenew = $backend->hash($sessionToRenew);
......
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Session;
/**
* Represents all information about a user's session.
* A user session can be bound to a frontend / backend user, or an anonymous session based on session data stored
* in the session backend.
*
* If a session is anonymous, it can be fixated by storing the session in the backend, but only if there
* is data in the session.
*
* if a session is user-bound, it is automatically fixated.
*
* The $isNew flag is meant to show that this user session object was not fetched from the session backend,
* but initialized in the first place by the current request.
*
* The $data argument is to store any arbitrary data valid for the users' session.
*
* A permanent session means: XYZ?
*/
class UserSession
{
protected const SESSION_UPDATE_GRACE_PERIOD = 61;
protected string $identifier;
protected ?int $userId;
protected int $lastUpdated;
protected array $data;
protected bool $wasUpdated = false;
protected string $ipLock = '';
protected bool $isNew = true;
protected bool $isPermanent = false;
protected function __construct(string $identifier, int $userId, int $lastUpdated, array $data = [])
{
$this->identifier = $identifier;
$this->userId = $userId > 0 ? $userId : null;
$this->lastUpdated = $lastUpdated;
$this->data = $data;
}
/**
* Get the user session identifier (the ses_id)
*
* @return string
*/
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* Get the user id (ID of the user record to whom the session belongs)
*
* @return int
*/
public function getUserId(): ?int
{
return $this->userId;
}
/**
* Get the timestamp of the last session data update
*
* @return int
*/
public function getLastUpdated(): int
{
return $this->lastUpdated;
}
/**
* Set / update a data value for a given key.
* Throws an exception if the given key is empty.
*
* @param string $key The key whose value should be updated
* @param mixed $value The value or NULL to unset the key
*/
public function set(string $key, $value): void
{
if ($key === '') {
throw new \InvalidArgumentException('Argument key must not be empty', 1484312516);
}
if ($value === null) {
unset($this->data[$key]);
} else {
$this->data[$key] = $value;
}
$this->wasUpdated = true;
}
/**
* Check whether the session has data
*
* @return bool
*/
public function hasData(): bool
{
return $this->data !== [];
}
/**
* Return the data for the given key or an NULL if the key does not exist
*
* @param string $key
* @return mixed
*/
public function get(string $key)
{
return $this->data[$key] ?? null;
}
/**
* Return the whole session data array.
*
* @return array
*/
public function getData(): array
{
return $this->data;
}
/**
* Override the whole $data. Can be used to e.g. preserve session data
* on login or to remove session data by providing an empty array.
*
* @param array $data
*/
public function overrideData(array $data): void
{
if ($this->data !== $data) {
// Only set update flag if there is change in the $data array
$this->wasUpdated = true;
}
$this->data = $data;
}
/**
* Check if session data was already updated
*
* @return bool
*/
public function dataWasUpdated(): bool
{
return $this->wasUpdated;
}
/**
* Check if the user session is an anonymous one.
* This means, the session does not belong to a logged-in user.
*
* @return bool
*/
public function isAnonymous(): bool
{
return $this->userId === 0 || $this->userId === null;
}
/**
* Return the sessions ipLock state
*
* @return string
*/
public function getIpLock(): string
{
return $this->ipLock;
}
/**
* Check whether the session was marked as new on creation
*
* @return bool
*/
public function isNew(): bool