[!!!][FEATURE] Introduce Session Framework 49/51549/7
authorMarkus Klein <markus.klein@typo3.org>
Tue, 22 Sep 2015 16:56:51 +0000 (18:56 +0200)
committerAndreas Fernandez <typo3@scripting-base.de>
Mon, 6 Feb 2017 19:21:24 +0000 (20:21 +0100)
A new session framework is introduced.
The goal is to provide interoperability between different
session storages (called "backends"), like database, Redis, etc.

An integrator may enforce a specific session backend by configuring
SYS/session in LocalConfiguration.php. It is also possible to use
custom session backends by implementing
the interface "SessionBackendInterface".

Resolves: #70316
Releases: master
Change-Id: I90a4f84344e75f13b2f46245162e749ed3505ec3
Reviewed-on: https://review.typo3.org/51549
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Claus Due <claus@phpmind.net>
Reviewed-by: Andreas Fernandez <typo3@scripting-base.de>
Tested-by: Andreas Fernandez <typo3@scripting-base.de>
Reviewed-by: Markus Klein <markus.klein@typo3.org>
Tested-by: Markus Klein <markus.klein@typo3.org>
30 files changed:
typo3/sysext/beuser/Classes/Controller/BackendUserController.php
typo3/sysext/beuser/Classes/Domain/Repository/BackendUserRepository.php
typo3/sysext/beuser/Classes/Domain/Repository/BackendUserSessionRepository.php
typo3/sysext/beuser/Classes/Hook/SwitchBackUserHook.php
typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php
typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php
typo3/sysext/core/Classes/Session/Backend/DatabaseSessionBackend.php [new file with mode: 0644]
typo3/sysext/core/Classes/Session/Backend/Exception/AbstractBackendException.php [new file with mode: 0644]
typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotCreatedException.php [new file with mode: 0644]
typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotFoundException.php [new file with mode: 0644]
typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotUpdatedException.php [new file with mode: 0644]
typo3/sysext/core/Classes/Session/Backend/RedisSessionBackend.php [new file with mode: 0644]
typo3/sysext/core/Classes/Session/Backend/SessionBackendInterface.php [new file with mode: 0644]
typo3/sysext/core/Classes/Session/SessionManager.php [new file with mode: 0644]
typo3/sysext/core/Configuration/DefaultConfiguration.php
typo3/sysext/core/Documentation/Changelog/master/Breaking-70316-AbstractUserAuthenticationPropertiesAndMethodsDroppedAndChanged.rst [new file with mode: 0644]
typo3/sysext/core/Documentation/Changelog/master/Deprecation-70316-FrontendBasketWithRecs.rst [new file with mode: 0644]
typo3/sysext/core/Documentation/Changelog/master/Feature-70316-IntroduceSessionStorageFramework.rst [new file with mode: 0644]
typo3/sysext/core/Tests/Functional/Session/Backend/DatabaseSessionBackendTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Functional/Session/Backend/RedisSessionBackendTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php
typo3/sysext/core/Tests/Unit/Cache/Backend/RedisBackendTest.php
typo3/sysext/core/Tests/Unit/Session/Backend/DatabaseSessionBackendTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Session/Backend/RedisSessionBackendTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Session/SessionManagerTest.php [new file with mode: 0644]
typo3/sysext/frontend/Classes/Authentication/FrontendUserAuthentication.php
typo3/sysext/frontend/Classes/Controller/TypoScriptFrontendController.php
typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php [new file with mode: 0644]
typo3/sysext/frontend/ext_tables.sql
typo3/sysext/install/Classes/Controller/Action/Tool/CleanUp.php

index e4c29ed..e5b60db 100644 (file)
@@ -16,7 +16,8 @@ namespace TYPO3\CMS\Beuser\Controller;
 
 use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
-use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
+use TYPO3\CMS\Core\Session\SessionManager;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
 use TYPO3\CMS\Lang\LanguageService;
@@ -232,24 +233,10 @@ class BackendUserController extends BackendUserActionController
      */
     protected function terminateBackendUserSessionAction(\TYPO3\CMS\Beuser\Domain\Model\BackendUser $backendUser, $sessionId)
     {
-        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
-            ->getQueryBuilderForTable('be_sessions');
+        $sessionBackend = $this->getSessionBackend();
+        $success = $sessionBackend->remove($sessionId);
 
-        $affectedRows = $queryBuilder
-            ->delete('be_sessions')
-            ->where(
-                $queryBuilder->expr()->eq(
-                    'ses_userid',
-                    $queryBuilder->createNamedParameter($backendUser->getUid(), \PDO::PARAM_INT)
-                ),
-                $queryBuilder->expr()->eq(
-                    'ses_id',
-                    $queryBuilder->createNamedParameter($sessionId, \PDO::PARAM_STR)
-                )
-            )
-            ->execute();
-
-        if ($affectedRows === 1) {
+        if ($success) {
             $this->addFlashMessage(LocalizationUtility::translate('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:terminateSessionSuccess', 'beuser'));
         }
         $this->forward('online');
@@ -269,37 +256,14 @@ class BackendUserController extends BackendUserActionController
             $this->getBackendUserAuthentication()->uc['startModuleOnFirstLogin'] = 'system_BeuserTxBeuser';
             $this->getBackendUserAuthentication()->writeUC();
 
-            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
-                ->getQueryBuilderForTable('be_sessions');
-
-            $queryBuilder
-                ->update('be_sessions')
-                ->where(
-                    $queryBuilder->expr()->eq(
-                        'ses_id',
-                        $queryBuilder->createNamedParameter(
-                            $this->getBackendUserAuthentication()->id,
-                            \PDO::PARAM_STR
-                        )
-                    ),
-                    $queryBuilder->expr()->eq(
-                        'ses_name',
-                        $queryBuilder->createNamedParameter(
-                            \TYPO3\CMS\Core\Authentication\BackendUserAuthentication::getCookieName(),
-                            \PDO::PARAM_STR
-                        )
-                    ),
-                    $queryBuilder->expr()->eq(
-                        'ses_userid',
-                        $queryBuilder->createNamedParameter(
-                            $this->getBackendUserAuthentication()->user['uid'],
-                            \PDO::PARAM_INT
-                        )
-                    )
-                )
-                ->set('ses_userid', (int)$targetUser['uid'])
-                ->set('ses_backuserid', (int)$this->getBackendUserAuthentication()->user['uid'])
-                ->execute();
+            $sessionBackend = $this->getSessionBackend();
+            $sessionBackend->update(
+                $this->getBackendUserAuthentication()->getSessionId(),
+                [
+                    'ses_userid' => (int)$targetUser['uid'],
+                    'ses_backuserid' => (int)$this->getBackendUserAuthentication()->user['uid']
+                ]
+            );
 
             $redirectUrl = 'index.php' . ($GLOBALS['TYPO3_CONF_VARS']['BE']['interfaces'] ? '' : '?commandLI=1');
             \TYPO3\CMS\Core\Utility\HttpUtility::redirect($redirectUrl);
@@ -321,4 +285,13 @@ class BackendUserController extends BackendUserActionController
     {
         return $GLOBALS['LANG'];
     }
+
+    /**
+     * @return SessionBackendInterface
+     */
+    protected function getSessionBackend()
+    {
+        $loginType = $this->getBackendUserAuthentication()->getLoginType();
+        return GeneralUtility::makeInstance(SessionManager::class)->getSessionBackend($loginType);
+    }
 }
index 847d44b..d565eeb 100644 (file)
@@ -14,13 +14,20 @@ namespace TYPO3\CMS\Beuser\Domain\Repository;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Beuser\Domain\Model\Demand;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
+use TYPO3\CMS\Core\Session\SessionManager;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Domain\Repository\BackendUserGroupRepository;
+use TYPO3\CMS\Extbase\Persistence\Generic\QueryResult;
+use TYPO3\CMS\Extbase\Persistence\QueryInterface;
 
 /**
  * Repository for \TYPO3\CMS\Beuser\Domain\Model\BackendUser
  */
-class BackendUserRepository extends \TYPO3\CMS\Extbase\Domain\Repository\BackendUserGroupRepository
+class BackendUserRepository extends BackendUserGroupRepository
 {
     /**
      * Finds Backend Users on a given list of uids
@@ -31,22 +38,25 @@ class BackendUserRepository extends \TYPO3\CMS\Extbase\Domain\Repository\Backend
     public function findByUidList(array $uidList)
     {
         $query = $this->createQuery();
-        return $query->matching($query->in('uid', array_map('intval', $uidList)))->execute();
+        $query->matching($query->in('uid', array_map('intval', $uidList)));
+        /** @var QueryResult $result */
+        $result = $query->execute();
+        return $result;
     }
 
     /**
      * Find Backend Users matching to Demand object properties
      *
-     * @param \TYPO3\CMS\Beuser\Domain\Model\Demand $demand
+     * @param Demand $demand
      * @return \TYPO3\CMS\Extbase\Persistence\Generic\QueryResult<\TYPO3\CMS\Beuser\Domain\Model\BackendUser>
      */
-    public function findDemanded(\TYPO3\CMS\Beuser\Domain\Model\Demand $demand)
+    public function findDemanded(Demand $demand)
     {
         $constraints = [];
         $query = $this->createQuery();
         // Find invisible as well, but not deleted
         $constraints[] = $query->equals('deleted', 0);
-        $query->setOrderings(['userName' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_ASCENDING]);
+        $query->setOrderings(['userName' => QueryInterface::ORDER_ASCENDING]);
         // Username
         if ($demand->getUserName() !== '') {
             $searchConstraints = [];
@@ -59,42 +69,44 @@ class BackendUserRepository extends \TYPO3\CMS\Extbase\Domain\Repository\Backend
             $constraints[] = $query->logicalOr($searchConstraints);
         }
         // Only display admin users
-        if ($demand->getUserType() == \TYPO3\CMS\Beuser\Domain\Model\Demand::USERTYPE_ADMINONLY) {
+        if ($demand->getUserType() == Demand::USERTYPE_ADMINONLY) {
             $constraints[] = $query->equals('admin', 1);
         }
         // Only display non-admin users
-        if ($demand->getUserType() == \TYPO3\CMS\Beuser\Domain\Model\Demand::USERTYPE_USERONLY) {
+        if ($demand->getUserType() == Demand::USERTYPE_USERONLY) {
             $constraints[] = $query->equals('admin', 0);
         }
         // Only display active users
-        if ($demand->getStatus() == \TYPO3\CMS\Beuser\Domain\Model\Demand::STATUS_ACTIVE) {
+        if ($demand->getStatus() == Demand::STATUS_ACTIVE) {
             $constraints[] = $query->equals('disable', 0);
         }
         // Only display in-active users
-        if ($demand->getStatus() == \TYPO3\CMS\Beuser\Domain\Model\Demand::STATUS_INACTIVE) {
+        if ($demand->getStatus() == Demand::STATUS_INACTIVE) {
             $constraints[] = $query->logicalOr($query->equals('disable', 1));
         }
         // Not logged in before
-        if ($demand->getLogins() == \TYPO3\CMS\Beuser\Domain\Model\Demand::LOGIN_NONE) {
+        if ($demand->getLogins() == Demand::LOGIN_NONE) {
             $constraints[] = $query->equals('lastlogin', 0);
         }
         // At least one login
-        if ($demand->getLogins() == \TYPO3\CMS\Beuser\Domain\Model\Demand::LOGIN_SOME) {
+        if ($demand->getLogins() == Demand::LOGIN_SOME) {
             $constraints[] = $query->logicalNot($query->equals('lastlogin', 0));
         }
         // In backend user group
         // @TODO: Refactor for real n:m relations
         if ($demand->getBackendUserGroup()) {
-            $constraints[] = $query->logicalOr(
+            $constraints[] = $query->logicalOr([
                 $query->equals('usergroup', (int)$demand->getBackendUserGroup()->getUid()),
                 $query->like('usergroup', (int)$demand->getBackendUserGroup()->getUid() . ',%'),
                 $query->like('usergroup', '%,' . (int)$demand->getBackendUserGroup()->getUid()),
                 $query->like('usergroup', '%,' . (int)$demand->getBackendUserGroup()->getUid() . ',%')
-            );
+            ]);
             $query->contains('usergroup', $demand->getBackendUserGroup());
         }
         $query->matching($query->logicalAnd($constraints));
-        return $query->execute();
+        /** @var QueryResult $result */
+        $result = $query->execute();
+        return $result;
     }
 
     /**
@@ -105,28 +117,23 @@ class BackendUserRepository extends \TYPO3\CMS\Extbase\Domain\Repository\Backend
     public function findOnline()
     {
         $uids = [];
-
-        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_sessions');
-
-        $res = $queryBuilder
-            ->select('ses_userid')
-            ->from('be_sessions')
-            ->groupBy('ses_userid')
-            ->execute();
-
-        while ($row = $res->fetch()) {
-            $uids[] = $row['ses_userid'];
+        foreach ($this->getSessionBackend()->getAll() as $sessionRecord) {
+            if (isset($sessionRecord['ses_userid']) && !in_array($sessionRecord['ses_userid'], $uids, true)) {
+                $uids[] = $sessionRecord['ses_userid'];
+            }
         }
 
         $query = $this->createQuery();
         $query->matching($query->in('uid', $uids));
-        return $query->execute();
+        /** @var QueryResult $result */
+        $result = $query->execute();
+        return $result;
     }
 
     /**
      * Overwrite createQuery to don't respect enable fields
      *
-     * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface
+     * @return QueryInterface
      */
     public function createQuery()
     {
@@ -135,4 +142,21 @@ class BackendUserRepository extends \TYPO3\CMS\Extbase\Domain\Repository\Backend
         $query->getQuerySettings()->setIncludeDeleted(true);
         return $query;
     }
+
+    /**
+     * @return SessionBackendInterface
+     */
+    protected function getSessionBackend()
+    {
+        $loginType = $this->getBackendUserAuthentication()->getLoginType();
+        return GeneralUtility::makeInstance(SessionManager::class)->getSessionBackend($loginType);
+    }
+
+    /**
+     * @return BackendUserAuthentication
+     */
+    protected function getBackendUserAuthentication()
+    {
+        return $GLOBALS['BE_USER'];
+    }
 }
index c33cff1..9529c80 100644 (file)
@@ -13,10 +13,11 @@ namespace TYPO3\CMS\Beuser\Domain\Repository;
  *
  * The TYPO3 project - inspiring people to share!
  */
+
 use TYPO3\CMS\Beuser\Domain\Model\BackendUser;
 use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
-use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
-use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
+use TYPO3\CMS\Core\Session\SessionManager;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Extbase\Persistence\Repository;
 
@@ -28,40 +29,50 @@ class BackendUserSessionRepository extends Repository
     /**
      * Find all active sessions for all backend users
      *
-     * @return array|NULL Array of rows, or NULL in case of SQL error
+     * @return array
      */
     public function findAllActive()
     {
-        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_sessions');
-        return $queryBuilder
-            ->select('ses_id AS id', 'ses_userid', 'ses_iplock AS ip', 'ses_tstamp AS timestamp')
-            ->from('be_sessions')
-            ->execute()
-            ->fetchAll();
+        $sessionBackend = $this->getSessionBackend();
+        $allSessions = $sessionBackend->getAll();
+
+        // Map array to correct keys
+        $allSessions = array_map(
+            function ($session) {
+                return [
+                    'id' => $session['ses_id'],
+                    'ip' => $session['ses_iplock'],
+                    'timestamp' => $session['ses_tstamp'],
+                    'ses_userid' => $session['ses_userid']
+                ];
+            },
+            $allSessions
+        );
+
+        // Sort by timestamp
+        usort($allSessions, function ($session1, $session2) {
+            return $session1['timestamp'] <=> $session2['timestamp'];
+        });
+
+        return $allSessions;
     }
 
     /**
      * Find Sessions for specific BackendUser
-     * Delivers an Array, not an ObjectStorage!
      *
      * @param BackendUser $backendUser
      * @return array
      */
     public function findByBackendUser(BackendUser $backendUser)
     {
-        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_sessions');
-        return $queryBuilder
-            ->select('ses_id AS id', 'ses_iplock AS ip', 'ses_tstamp AS timestamp')
-            ->from('be_sessions')
-            ->where(
-                $queryBuilder->expr()->eq(
-                    'ses_userid',
-                    $queryBuilder->createNamedParameter($backendUser->getUid(), \PDO::PARAM_INT)
-                )
-            )
-            ->orderBy('ses_tstamp', 'ASC')
-            ->execute()
-            ->fetchAll();
+        $allActive = $this->findAllActive();
+
+        return array_filter(
+            $allActive,
+            function ($session) use ($backendUser) {
+                return (int)$session['ses_userid'] === $backendUser->getUid();
+            }
+        );
     }
 
     /**
@@ -72,25 +83,30 @@ class BackendUserSessionRepository extends Repository
      */
     public function switchBackToOriginalUser(AbstractUserAuthentication $authentication)
     {
-        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_sessions');
-        $queryBuilder
-            ->update('be_sessions')
-            ->set('ses_userid', $authentication->user['ses_backuserid'])
-            ->set('ses_backuserid', 0)
-            ->where(
-                $queryBuilder->expr()->eq(
-                    'ses_id',
-                    $queryBuilder->createNamedParameter($GLOBALS['BE_USER']->id, \PDO::PARAM_STR)
-                ),
-                $queryBuilder->expr()->eq(
-                    'ses_name',
-                    $queryBuilder->createNamedParameter(BackendUserAuthentication::getCookieName(), \PDO::PARAM_STR)
-                ),
-                $queryBuilder->expr()->eq(
-                    'ses_userid',
-                    $queryBuilder->createNamedParameter($GLOBALS['BE_USER']->user['uid'], \PDO::PARAM_INT)
-                )
-            )
-            ->execute();
+        $sessionBackend = $this->getSessionBackend();
+        $sessionId = $this->getBackendSessionId();
+        $sessionBackend->update(
+            $sessionId,
+            [
+                'ses_userid' => $authentication->user['ses_backuserid'],
+                'ses_backuserid' => 0
+            ]
+        );
+    }
+
+    /**
+     * @return string
+     */
+    protected function getBackendSessionId(): string
+    {
+        return $GLOBALS['BE_USER']->id;
+    }
+
+    /**
+     * @return SessionBackendInterface
+     */
+    protected function getSessionBackend(): SessionBackendInterface
+    {
+        return GeneralUtility::makeInstance(SessionManager::class)->getSessionBackend('BE');
     }
 }
index bfcffc1..55e2671 100644 (file)
@@ -15,6 +15,7 @@ namespace TYPO3\CMS\Beuser\Hook;
  */
 
 use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Utility\HttpUtility;
 
@@ -51,15 +52,9 @@ class SwitchBackUserHook
      */
     protected function isAHandledBackendSession(AbstractUserAuthentication $authentication)
     {
-        if (
-            $authentication->session_table !== 'be_sessions'
-            || !is_array($authentication->user)
-            || !$authentication->user['uid']
-            || !$authentication->user['ses_backuserid']
-        ) {
-            return false;
-        } else {
-            return true;
-        }
+        return ($authentication instanceof BackendUserAuthentication)
+            && is_array($authentication->user)
+            && (int)$authentication->user['uid'] > 0
+            && (int)$authentication->user['ses_backuserid'] > 0;
     }
 }
index 29882d6..aac1aaf 100644 (file)
@@ -27,8 +27,12 @@ use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface
 use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
 use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
 use TYPO3\CMS\Core\Exception;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
+use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
+use TYPO3\CMS\Core\Session\SessionManager;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Utility\MathUtility;
+use TYPO3\CMS\Sv\AuthenticationService;
 
 /**
  * Authentication of users in TYPO3
@@ -42,12 +46,6 @@ use TYPO3\CMS\Core\Utility\MathUtility;
 abstract class AbstractUserAuthentication
 {
     /**
-     * Table to use for session data
-     * @var string
-     */
-    public $session_table = '';
-
-    /**
      * Session/Cookie name
      * @var string
      */
@@ -111,7 +109,7 @@ abstract class AbstractUserAuthentication
         'disabled' => '',
         'starttime' => '',
         'endtime' => '',
-        'deleted' => ''
+        'deleted' => '',
     ];
 
     /**
@@ -174,7 +172,7 @@ abstract class AbstractUserAuthentication
     public $gc_time = 0;
 
     /**
-     * Probability for g arbage collection to be run (in percent)
+     * Probability for garbage collection to be run (in percent)
      * @var int
      */
     public $gc_probability = 1;
@@ -338,6 +336,20 @@ abstract class AbstractUserAuthentication
     public $uc;
 
     /**
+     * @var SessionBackendInterface
+     */
+    protected $sessionBackend;
+
+    /**
+     * Holds deserialized data from session records.
+     * 'Reserved' keys are:
+     *   - 'recs': (DEPRECATED) Array: Used to 'register' records, eg in a shopping basket. Structure: [recs][tablename][record_uid]=number
+     *   - 'sys': Reserved for TypoScript standard code.
+     * @var array
+     */
+    protected $sessionData = [];
+
+    /**
      * Initialize some important variables
      */
     public function __construct()
@@ -438,7 +450,7 @@ abstract class AbstractUserAuthentication
         if (is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postUserLookUp'])) {
             foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postUserLookUp'] as $funcName) {
                 $_params = [
-                    'pObj' => &$this
+                    'pObj' => $this,
                 ];
                 GeneralUtility::callUserFunction($funcName, $_params, $this);
             }
@@ -598,18 +610,23 @@ abstract class AbstractUserAuthentication
         // This is used mainly for checking session timeout in advance without refreshing the current session's timeout.
         $skipSessionUpdate = (bool)GeneralUtility::_GP('skipSessionUpdate');
         $haveSession = false;
+        $anonymousSession = false;
         if (!$this->newSessionID) {
             // Read user session
             $authInfo['userSession'] = $this->fetchUserSession($skipSessionUpdate);
             $haveSession = is_array($authInfo['userSession']);
+            if ($haveSession && !empty($authInfo['userSession']['ses_anonymous'])) {
+                $anonymousSession = true;
+            }
         }
-        // Active login (eg. with login form)
+
+        // Active login (eg. with login form).
         if (!$haveSession && $loginData['status'] === 'login') {
             $activeLogin = true;
             if ($this->writeDevLog) {
                 GeneralUtility::devLog('Active login (eg. with login form)', self::class);
             }
-            // check referer for submitted login values
+            // check referrer for submitted login values
             if ($this->formfield_status && $loginData['uident'] && $loginData['uname']) {
                 $httpHost = GeneralUtility::getIndpEnv('TYPO3_HOST_ONLY');
                 if (!$this->getMethodEnabled && ($httpHost != $authInfo['refInfo']['host'] && !$GLOBALS['TYPO3_CONF_VARS']['SYS']['doNotCheckReferer'])) {
@@ -626,6 +643,12 @@ abstract class AbstractUserAuthentication
                 throw new \RuntimeException('TYPO3 Fatal Error: You have tried to login using a CLI user. Access prohibited!', 1270853931);
             }
         }
+
+        // Cause elevation of privilege, make sure regenerateSessionId is called later on
+        if ($anonymousSession && $loginData['status'] === 'login') {
+            $activeLogin = true;
+        }
+
         if ($this->writeDevLog) {
             if ($haveSession) {
                 GeneralUtility::devLog('User session found: ' . GeneralUtility::arrayToLogString($authInfo['userSession'], [$this->userid_column, $this->username_column]), self::class, 0);
@@ -636,6 +659,7 @@ abstract class AbstractUserAuthentication
                 GeneralUtility::devLog('SV setup: ' . GeneralUtility::arrayToLogString($this->svConfig['setup']), self::class, 0);
             }
         }
+
         // Fetch user if ...
         if (
             $activeLogin || $this->svConfig['setup'][$this->loginType . '_alwaysFetchUser']
@@ -643,11 +667,9 @@ abstract class AbstractUserAuthentication
         ) {
             // Use 'auth' service to find the user
             // First found user will be used
-            $serviceChain = '';
             $subType = 'getUser' . $this->loginType;
-            while (is_object($serviceObj = GeneralUtility::makeInstanceService('auth', $subType, $serviceChain))) {
-                $serviceChain .= ',' . $serviceObj->getServiceKey();
-                $serviceObj->initAuth($subType, $loginData, $authInfo, $this);
+            /** @var AuthenticationService $serviceObj */
+            foreach ($this->getAuthServices($subType, $loginData, $authInfo) as $serviceObj) {
                 if ($row = $serviceObj->getUser()) {
                     $tempuserArr[] = $row;
                     if ($this->writeDevLog) {
@@ -658,15 +680,11 @@ abstract class AbstractUserAuthentication
                         break;
                     }
                 }
-                unset($serviceObj);
             }
-            unset($serviceObj);
+
             if ($this->writeDevLog && $this->svConfig['setup'][$this->loginType . '_alwaysFetchUser']) {
                 GeneralUtility::devLog($this->loginType . '_alwaysFetchUser option is enabled', self::class);
             }
-            if ($this->writeDevLog && $serviceChain) {
-                GeneralUtility::devLog($subType . ' auth services called: ' . $serviceChain, self::class);
-            }
             if ($this->writeDevLog && empty($tempuserArr)) {
                 GeneralUtility::devLog('No user found by services', self::class);
             }
@@ -674,8 +692,9 @@ abstract class AbstractUserAuthentication
                 GeneralUtility::devLog(count($tempuserArr) . ' user records found by services', self::class);
             }
         }
+
         // If no new user was set we use the already found user session
-        if (empty($tempuserArr) && $haveSession) {
+        if (empty($tempuserArr) && $haveSession && !$anonymousSession) {
             $tempuserArr[] = $authInfo['userSession'];
             $tempuser = $authInfo['userSession'];
             // User is authenticated because we found a user session
@@ -700,11 +719,9 @@ abstract class AbstractUserAuthentication
                 if ($this->writeDevLog) {
                     GeneralUtility::devLog('Auth user: ' . GeneralUtility::arrayToLogString($tempuser), self::class);
                 }
-                $serviceChain = '';
                 $subType = 'authUser' . $this->loginType;
-                while (is_object($serviceObj = GeneralUtility::makeInstanceService('auth', $subType, $serviceChain))) {
-                    $serviceChain .= ',' . $serviceObj->getServiceKey();
-                    $serviceObj->initAuth($subType, $loginData, $authInfo, $this);
+
+                foreach ($this->getAuthServices($subType, $loginData, $authInfo) as $serviceObj) {
                     if (($ret = $serviceObj->authUser($tempuser)) > 0) {
                         // If the service returns >=200 then no more checking is needed - useful for IP checking without password
                         if ((int)$ret >= 200) {
@@ -718,45 +735,52 @@ abstract class AbstractUserAuthentication
                         $authenticated = false;
                         break;
                     }
-                    unset($serviceObj);
-                }
-                unset($serviceObj);
-                if ($this->writeDevLog && $serviceChain) {
-                    GeneralUtility::devLog($subType . ' auth services called: ' . $serviceChain, self::class);
                 }
+
                 if ($authenticated) {
                     // Leave foreach() because a user is authenticated
                     break;
                 }
             }
         }
+
         // If user is authenticated a valid user is in $tempuser
         if ($authenticated) {
             // Reset failure flag
             $this->loginFailure = false;
             // Insert session record if needed:
-            if (!($haveSession && ($tempuser['ses_id'] == $this->id || $tempuser['uid'] == $authInfo['userSession']['ses_userid']))) {
+            if (!$haveSession || $anonymousSession || $tempuser['ses_id'] != $this->id && $tempuser['uid'] != $authInfo['userSession']['ses_userid']) {
                 $sessionData = $this->createUserSession($tempuser);
-                if ($sessionData) {
-                    $this->user = array_merge(
-                        $tempuser,
-                        $sessionData
+
+                // Preserve session data on login
+                if ($anonymousSession) {
+                    $sessionData = $this->getSessionBackend()->update(
+                        $this->id,
+                        ['ses_data' => $authInfo['userSession']['ses_data']]
                     );
                 }
+
+                $this->user = array_merge(
+                    $tempuser,
+                    $sessionData
+                );
                 // The login session is started.
                 $this->loginSessionStarted = true;
                 if ($this->writeDevLog && is_array($this->user)) {
                     GeneralUtility::devLog('User session finally read: ' . GeneralUtility::arrayToLogString($this->user, [$this->userid_column, $this->username_column]), self::class, -1);
                 }
             } elseif ($haveSession) {
+                // if we come here the current session is for sure not anonymous as this is a pre-condition for $authenticated = true
                 $this->user = $authInfo['userSession'];
             }
+
             if ($activeLogin && !$this->newSessionID) {
                 $this->regenerateSessionId();
             }
+
             // User logged in - write that to the log!
             if ($this->writeStdLog && $activeLogin) {
-                $this->writelog(255, 1, 0, 1, 'User %s logged in from %s (%s)', [$tempuser[$this->username_column], GeneralUtility::getIndpEnv('REMOTE_ADDR'), GeneralUtility::getIndpEnv('REMOTE_HOST')], '', '', '', -1, '', $tempuser['uid']);
+                $this->writelog(255, 1, 0, 1, 'User %s logged in from %s (%s)', [$tempuser[$this->username_column], GeneralUtility::getIndpEnv('REMOTE_ADDR'), GeneralUtility::getIndpEnv('REMOTE_HOST')], '', '', '');
             }
             if ($this->writeDevLog && $activeLogin) {
                 GeneralUtility::devLog('User ' . $tempuser[$this->username_column] . ' logged in from ' . GeneralUtility::getIndpEnv('REMOTE_ADDR') . ' (' . GeneralUtility::getIndpEnv('REMOTE_HOST') . ')', self::class, -1);
@@ -764,6 +788,9 @@ abstract class AbstractUserAuthentication
             if ($this->writeDevLog && !$activeLogin) {
                 GeneralUtility::devLog('User ' . $tempuser[$this->username_column] . ' authenticated from ' . GeneralUtility::getIndpEnv('REMOTE_ADDR') . ' (' . GeneralUtility::getIndpEnv('REMOTE_HOST') . ')', self::class, -1);
             }
+        } elseif ($anonymousSession) {
+            // User was not authenticated, so we should reuse the existing anonymous session
+            $this->user = $authInfo['userSession'];
         } elseif ($activeLogin || !empty($tempuserArr)) {
             $this->loginFailure = true;
             if ($this->writeDevLog && empty($tempuserArr) && $activeLogin) {
@@ -773,6 +800,7 @@ abstract class AbstractUserAuthentication
                 GeneralUtility::devLog('Login failed: ' . GeneralUtility::arrayToLogString($tempuser, [$this->userid_column, $this->username_column]), self::class, 2);
             }
         }
+
         // If there were a login failure, check to see if a warning email should be sent:
         if ($this->loginFailure && $activeLogin) {
             if ($this->writeDevLog) {
@@ -808,21 +836,52 @@ abstract class AbstractUserAuthentication
     }
 
     /**
+     * Initializes authentication services to be used in a foreach loop
+     *
+     * @param string $subType e.g. getUserFE
+     * @param array $loginData
+     * @param array $authInfo
+     * @return \Traversable A generator of service objects
+     */
+    protected function getAuthServices(string $subType, array $loginData, array $authInfo): \Traversable
+    {
+        $serviceChain = '';
+        while (is_object($serviceObj = GeneralUtility::makeInstanceService('auth', $subType, $serviceChain))) {
+            $serviceChain .= ',' . $serviceObj->getServiceKey();
+            $serviceObj->initAuth($subType, $loginData, $authInfo, $this);
+            yield $serviceObj;
+        }
+        if ($this->writeDevLog && $serviceChain) {
+            GeneralUtility::devLog($subType . ' auth services called: ' . $serviceChain, self::class);
+        }
+    }
+
+    /**
      * Regenerate the session ID and transfer the session to new ID
      * Call this method whenever a user proceeds to a higher authorization level
      * e.g. when an anonymous session is now authenticated.
+     *
+     * @param array $existingSessionRecord If given, this session record will be used instead of fetching again
+     * @param bool $anonymous If true session will be regenerated as anonymous session
      */
-    protected function regenerateSessionId()
+    protected function regenerateSessionId(array $existingSessionRecord = [], bool $anonymous = false)
     {
+        if (empty($existingSessionRecord)) {
+            $existingSessionRecord = $this->getSessionBackend()->get($this->id);
+        }
+
+        // Update session record with new ID
         $oldSessionId = $this->id;
         $this->id = $this->createSessionId();
-        // Update session record with new ID
-        GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->session_table)->update(
-            $this->session_table,
-            ['ses_id' => $this->id],
-            ['ses_id' => $oldSessionId, 'ses_name' => $this->name]
-        );
-        $this->user['ses_id'] = $this->id;
+        $existingSessionRecord['ses_anonymous'] = (int)$anonymous;
+        if ($anonymous) {
+            $existingSessionRecord['ses_userid'] = 0;
+        }
+        $updatedSession = $this->getSessionBackend()->set($this->id, $existingSessionRecord);
+        $this->sessionData = unserialize($updatedSession['ses_data']);
+        // Merge new session data into user/session array
+        $this->user = array_merge($this->user ?? [], $updatedSession);
+        $this->getSessionBackend()->remove($oldSessionId);
         $this->newSessionID = true;
     }
 
@@ -831,6 +890,7 @@ abstract class AbstractUserAuthentication
      * User Sessions
      *
      *************************/
+
     /**
      * Creates a user session record and returns its values.
      *
@@ -843,38 +903,31 @@ abstract class AbstractUserAuthentication
         if ($this->writeDevLog) {
             GeneralUtility::devLog('Create session ses_id = ' . $this->id, self::class);
         }
-        // Delete session entry first
-        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->session_table);
-        $connection->delete(
-            $this->session_table,
-            ['ses_id' => $this->id, 'ses_name' => $this->name]
-        );
-
+        // Delete any session entry first
+        $this->getSessionBackend()->remove($this->id);
         // Re-create session entry
-        $insertFields = $this->getNewSessionRecord($tempuser);
-        $inserted = (bool)$connection->insert(
-            $this->session_table,
-            $insertFields,
-            ['ses_data' => Connection::PARAM_LOB]
-        );
-        if (!$inserted) {
-            $message = 'Session data could not be written to DB. Error: ' . $connection->errorInfo();
-            GeneralUtility::sysLog($message, 'core', GeneralUtility::SYSLOG_SEVERITY_WARNING);
-            if ($this->writeDevLog) {
-                GeneralUtility::devLog($message, self::class, 2);
-            }
-        }
+        $sessionRecord = $this->getNewSessionRecord($tempuser);
+        $sessionRecord = $this->getSessionBackend()->set($this->id, $sessionRecord);
         // Updating lastLogin_column carrying information about last login.
-        if ($this->lastLogin_column && $inserted) {
+        $this->updateLoginTimestamp($tempuser[$this->userid_column]);
+        return $sessionRecord;
+    }
+
+    /**
+     * Updates the last login column in the user with the given id
+     *
+     * @param int $userId
+     */
+    protected function updateLoginTimestamp(int $userId)
+    {
+        if ($this->lastLogin_column) {
             $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->user_table);
             $connection->update(
                 $this->user_table,
                 [$this->lastLogin_column => $GLOBALS['EXEC_TIME']],
-                [$this->userid_column => $tempuser[$this->userid_column]]
+                [$this->userid_column => $userId]
             );
         }
-
-        return $inserted ? $insertFields : [];
     }
 
     /**
@@ -895,9 +948,9 @@ abstract class AbstractUserAuthentication
             'ses_id' => $this->id,
             'ses_name' => $this->name,
             'ses_iplock' => $sessionIpLock,
-            'ses_userid' => $tempuser[$this->userid_column],
+            'ses_userid' => $tempuser[$this->userid_column] ?? 0,
             'ses_tstamp' => $GLOBALS['EXEC_TIME'],
-            'ses_data' => ''
+            'ses_data' => '',
         ];
     }
 
@@ -905,47 +958,60 @@ abstract class AbstractUserAuthentication
      * Read the user session from db.
      *
      * @param bool $skipSessionUpdate
-     * @return array User session data
+     * @return array|bool User session data, false if $this->id does not represent valid session
      */
     public function fetchUserSession($skipSessionUpdate = false)
     {
         if ($this->writeDevLog) {
             GeneralUtility::devLog('Fetch session ses_id = ' . $this->id, self::class);
         }
+        try {
+            $sessionRecord = $this->getSessionBackend()->get($this->id);
+        } catch (SessionNotFoundException $e) {
+            return false;
+        }
+
+        // Fail if user session is not in current IPLock Range
+        if ($sessionRecord['ses_iplock'] !== $this->ipLockClause_remoteIPNumber($this->lockIP) && $sessionRecord['ses_iplock'] !== '[DISABLED]') {
+            return false;
+        }
 
-        // Fetch the user session from the DB
-        $user = $this->fetchUserSessionFromDB();
+        $this->sessionData = unserialize($sessionRecord['ses_data']);
+        // Session is anonymous so no need to fetch user
+        if ($sessionRecord['ses_anonymous']) {
+            return $sessionRecord;
+        }
 
-        if ($user) {
+        // Fetch the user from the DB
+        $userRecord = $this->getRawUserByUid((int)$sessionRecord['ses_userid']);
+        if ($userRecord) {
+            $userRecord = array_merge($sessionRecord, $userRecord);
             // A user was found
-            $user['ses_tstamp'] = (int)$user['ses_tstamp'];
+            $userRecord['ses_tstamp'] = (int)$userRecord['ses_tstamp'];
+            $userRecord['is_online'] = (int)$userRecord['ses_tstamp'];
 
             if (!empty($this->auth_timeout_field)) {
                 // Get timeout-time from usertable
-                $timeout = (int)$user[$this->auth_timeout_field];
+                $timeout = (int)$userRecord[$this->auth_timeout_field];
             } else {
                 $timeout = $this->sessionTimeout;
             }
             // If timeout > 0 (TRUE) and current time has not exceeded the latest sessions-time plus the timeout in seconds then accept user
             // Use a gracetime-value to avoid updating a session-record too often
-            if ($timeout > 0 && $GLOBALS['EXEC_TIME'] < $user['ses_tstamp'] + $timeout) {
+            if ($timeout > 0 && $GLOBALS['EXEC_TIME'] < $userRecord['ses_tstamp'] + $timeout) {
                 $sessionUpdateGracePeriod = 61;
-                if (!$skipSessionUpdate && $GLOBALS['EXEC_TIME'] > ($user['ses_tstamp'] + $sessionUpdateGracePeriod)) {
-                    GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->session_table)->update(
-                        $this->session_table,
-                        ['ses_tstamp' => $GLOBALS['EXEC_TIME']],
-                        ['ses_id' => $this->id, 'ses_name' => $this->name]
-                    );
-                    // Make sure that the timestamp is also updated in the array
-                    $user['ses_tstamp'] = $GLOBALS['EXEC_TIME'];
+                if (!$skipSessionUpdate && $GLOBALS['EXEC_TIME'] > ($userRecord['ses_tstamp'] + $sessionUpdateGracePeriod)) {
+                    // Update the session timestamp by writing a dummy update. (Backend will update the timestamp)
+                    $updatesSession = $this->getSessionBackend()->update($this->id, ['ses_name' => $userRecord['ses_name']]);
+                    $userRecord = array_merge($userRecord, $updatesSession);
                 }
             } else {
                 // Delete any user set...
                 $this->logoff();
-                $user = false;
+                $userRecord = false;
             }
         }
-        return $user;
+        return $userRecord;
     }
 
     /**
@@ -962,7 +1028,6 @@ abstract class AbstractUserAuthentication
         }
         // Release the locked records
         BackendUtility::lockRecords();
-        // Hook for pre-processing the logoff() method, requested and implemented by andreas.otto@dkd.de:
         if (is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['logoff_pre_processing'])) {
             $_params = [];
             foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['logoff_pre_processing'] as $_funcRef) {
@@ -971,14 +1036,7 @@ abstract class AbstractUserAuthentication
                 }
             }
         }
-
-        GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->session_table)->delete(
-            $this->session_table,
-            ['ses_id' => $this->id, 'ses_name' => $this->name]
-        );
-
-        $this->user = null;
-        // Hook for post-processing the logoff() method, requested and implemented by andreas.otto@dkd.de:
+        $this->performLogoff();
         if (is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['logoff_post_processing'])) {
             $_params = [];
             foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['logoff_post_processing'] as $_funcRef) {
@@ -990,6 +1048,21 @@ abstract class AbstractUserAuthentication
     }
 
     /**
+     * Perform the logoff action. Called from logoff() as a way to allow subclasses to override
+     * what happens when a user logs off, without needing to reproduce the hook calls and logging
+     * that happens in the public logoff() API method.
+     *
+     * @return void
+     */
+    protected function performLogoff()
+    {
+        if ($this->id) {
+            $this->getSessionBackend()->remove($this->id);
+        }
+        $this->user = null;
+    }
+
+    /**
      * Empty / unset the cookie
      *
      * @param string $cookieName usually, this is $this->name
@@ -1004,22 +1077,19 @@ abstract class AbstractUserAuthentication
     }
 
     /**
-     * Determine whether there's an according session record to a given session_id
-     * in the database. Don't care if session record is still valid or not.
+     * Determine whether there's an according session record to a given session_id.
+     * Don't care if session record is still valid or not.
      *
-     * @param int $id Claimed Session ID
+     * @param string $id Claimed Session ID
      * @return bool Returns TRUE if a corresponding session was found in the database
      */
     public function isExistingSessionRecord($id)
     {
-        $conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->session_table);
-        $count = $conn->count(
-            '*',
-            $this->session_table,
-            ['ses_id' => $id]
-        );
-
-        return (bool)$count;
+        try {
+            return !empty($this->getSessionBackend()->get($id));
+        } catch (SessionNotFoundException $e) {
+            return false;
+        }
     }
 
     /**
@@ -1039,61 +1109,11 @@ abstract class AbstractUserAuthentication
      *
      *************************/
     /**
-     * The session_id is used to find user in the database.
-     * Two tables are joined: The session-table with user_id of the session and the usertable with its primary key
-     * if the client is flash (e.g. from a flash application inside TYPO3 that does a server request)
-     * then don't evaluate with the hashLockClause, as the client/browser is included in this hash
-     * and thus, the flash request would be rejected
-     *
-     * @return array|false
-     * @access private
-     */
-    protected function fetchUserSessionFromDB()
-    {
-        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
-            ->getQueryBuilderForTable($this->session_table);
-        $queryBuilder->setRestrictions($this->userConstraints());
-        $queryBuilder->select('*')
-            ->from($this->session_table)
-            ->from($this->user_table)
-            ->where(
-                $queryBuilder->expr()->eq(
-                    $this->session_table . '.ses_id',
-                    $queryBuilder->createNamedParameter($this->id, \PDO::PARAM_STR)
-                ),
-                $queryBuilder->expr()->eq(
-                    $this->session_table . '.ses_name',
-                    $queryBuilder->createNamedParameter($this->name, \PDO::PARAM_STR)
-                ),
-                // Condition on which to join the session and user table
-                $queryBuilder->expr()->eq(
-                    $this->session_table . '.ses_userid',
-                    $queryBuilder->quoteIdentifier($this->user_table . '.' . $this->userid_column)
-                )
-            );
-
-        if ($this->lockIP) {
-            $queryBuilder->andWhere(
-                $queryBuilder->expr()->in(
-                    $this->session_table . '.ses_iplock',
-                    $queryBuilder->createNamedParameter(
-                        [$this->ipLockClause_remoteIPNumber($this->lockIP), '[DISABLED]'],
-                        Connection::PARAM_STR_ARRAY // Automatically expand the array into multiple named parameters
-                    )
-                )
-            );
-        }
-
-        // Force the fetch mode to ensure we get back an array independently of the default fetch mode.
-        return $queryBuilder->execute()->fetch(\PDO::FETCH_ASSOC);
-    }
-
-    /**
      * This returns the restrictions needed to select the user respecting
      * enable columns and flags like deleted, hidden, starttime, endtime
      * and rootLevel
      *
-     * @return \TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface
+     * @return QueryRestrictionContainerInterface
      * @internal
      */
     protected function userConstraints(): QueryRestrictionContainerInterface
@@ -1137,7 +1157,7 @@ abstract class AbstractUserAuthentication
 
         $whereClause = '';
         if ($this->enablecolumns['rootLevel']) {
-            $whereClause .= 'AND ' . $this->user_table . '.pid=0 ';
+            $whereClause .= ' AND ' . $this->user_table . '.pid=0 ';
         }
         if ($this->enablecolumns['disabled']) {
             $whereClause .= ' AND ' . $this->user_table . '.' . $this->enablecolumns['disabled'] . '=0';
@@ -1156,32 +1176,6 @@ abstract class AbstractUserAuthentication
     }
 
     /**
-     * This returns the where prepared statement-clause needed to lock a user to the IP address
-     *
-     * @return array
-     * @access private
-     * @deprecated since TYPO3 v8, will be removed in TYPO3 v9
-     */
-    protected function ipLockClause()
-    {
-        GeneralUtility::logDeprecatedFunction();
-        $statementClause = [
-            'where' => '',
-            'parameters' => []
-        ];
-        if ($this->lockIP) {
-            $statementClause['where'] = 'AND (
-                               ' . $this->session_table . '.ses_iplock = :ses_iplock
-                               OR ' . $this->session_table . '.ses_iplock=\'[DISABLED]\'
-                               )';
-            $statementClause['parameters'] = [
-                ':ses_iplock' => $this->ipLockClause_remoteIPNumber($this->lockIP)
-            ];
-        }
-        return $statementClause;
-    }
-
-    /**
      * Returns the IP address to lock to.
      * The IP address may be partial based on $parts.
      *
@@ -1242,7 +1236,7 @@ abstract class AbstractUserAuthentication
                     self::class
                 );
             }
-            GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->session_table)->update(
+            GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->user_table)->update(
                 $this->user_table,
                 ['uc' => serialize($variable)],
                 [$this->userid_column => (int)$this->user[$this->userid_column]],
@@ -1304,15 +1298,30 @@ abstract class AbstractUserAuthentication
 
     /**
      * Returns the session data stored for $key.
-     * The data will last only for this login session since it is stored in the session table.
+     * The data will last only for this login session since it is stored in the user session.
      *
-     * @param string $key Pointer to an associative key in the session data array which is stored serialized in the field "ses_data" of the session table.
+     * @param string $key The key associated with the session data
      * @return mixed
      */
     public function getSessionData($key)
     {
-        $sesDat = unserialize($this->user['ses_data']);
-        return $sesDat[$key];
+        return $this->sessionData[$key] ?? null;
+    }
+
+    /**
+     * Set session data by key.
+     * The data will last only for this login session since it is stored in the user session.
+     *
+     * @param string $key A non empty string to store the data under
+     * @param mixed $data Data store store in session
+     * @return void
+     */
+    public function setSessionData($key, $data)
+    {
+        if (empty($key)) {
+            throw new \InvalidArgumentException('Argument key must not be empty', 1484311516);
+        }
+        $this->sessionData[$key] = $data;
     }
 
     /**
@@ -1320,23 +1329,21 @@ abstract class AbstractUserAuthentication
      * The data will last only for this login session since it is stored in the session table.
      *
      * @param string $key Pointer to an associative key in the session data array which is stored serialized in the field "ses_data" of the session table.
-     * @param mixed $data The variable to store in index $key
+     * @param mixed $data The data to store in index $key
      * @return void
      */
     public function setAndSaveSessionData($key, $data)
     {
-        $sesDat = unserialize($this->user['ses_data']);
-        $sesDat[$key] = $data;
-        $this->user['ses_data'] = serialize($sesDat);
+        $this->sessionData[$key] = $data;
+        $this->user['ses_data'] = serialize($this->sessionData);
         if ($this->writeDevLog) {
-            GeneralUtility::devLog('setAndSaveSessionData: ses_id = ' . $this->user['ses_id'], self::class);
+            GeneralUtility::devLog('setAndSaveSessionData: ses_id = ' . $this->id, self::class);
         }
-        GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->session_table)->update(
-            $this->session_table,
-            ['ses_data' => $this->user['ses_data']],
-            ['ses_id' => $this->user['ses_id']],
-            ['ses_data' => Connection::PARAM_LOB]
+        $updatedSession = $this->getSessionBackend()->update(
+            $this->id,
+            ['ses_data' => $this->user['ses_data']]
         );
+        $this->user = array_merge($this->user ?? [], $updatedSession);
     }
 
     /*************************
@@ -1475,19 +1482,7 @@ abstract class AbstractUserAuthentication
      */
     public function gc()
     {
-        $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->session_table);
-        $query->delete($this->session_table)
-            ->where(
-                $query->expr()->lt(
-                    'ses_tstamp',
-                    $query->createNamedParameter(($GLOBALS['EXEC_TIME'] - $this->gc_time), \PDO::PARAM_INT)
-                ),
-                $query->expr()->eq(
-                    'ses_name',
-                    $query->createNamedParameter($this->name, \PDO::PARAM_STR)
-                )
-            )
-            ->execute();
+        $this->getSessionBackend()->collectGarbage($this->gc_time);
     }
 
     /**
@@ -1589,11 +1584,6 @@ abstract class AbstractUserAuthentication
         return $query->execute()->fetch();
     }
 
-    /*************************
-     *
-     * Create/update user - EXPERIMENTAL
-     *
-     *************************/
     /**
      * Get a user from DB by username
      * provided for usage from services
@@ -1636,4 +1626,35 @@ abstract class AbstractUserAuthentication
 
         return $user;
     }
+
+    /**
+     * @internal
+     * @return string
+     */
+    public function getSessionId() : string
+    {
+        return $this->id;
+    }
+
+    /**
+     * @internal
+     * @return string
+     */
+    public function getLoginType() : string
+    {
+        return $this->loginType;
+    }
+
+    /**
+     * Returns initialized session backend. Returns same session backend if called multiple times
+     *
+     * @return SessionBackendInterface
+     */
+    protected function getSessionBackend()
+    {
+        if (!isset($this->sessionBackend)) {
+            $this->sessionBackend = GeneralUtility::makeInstance(SessionManager::class)->getSessionBackend($this->loginType);
+        }
+        return $this->sessionBackend;
+    }
 }
index 48d8d46..b7c8887 100644 (file)
@@ -173,12 +173,6 @@ class BackendUserAuthentication extends \TYPO3\CMS\Core\Authentication\AbstractU
     protected $filePermissions;
 
     /**
-     * Table to use for session data
-     * @var string
-     */
-    public $session_table = 'be_sessions';
-
-    /**
      * Table in database with user data
      * @var string
      */
diff --git a/typo3/sysext/core/Classes/Session/Backend/DatabaseSessionBackend.php b/typo3/sysext/core/Classes/Session/Backend/DatabaseSessionBackend.php
new file mode 100644 (file)
index 0000000..d293cad
--- /dev/null
@@ -0,0 +1,229 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Session\Backend;
+
+/*
+ * 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!
+ */
+
+use Doctrine\DBAL\DBALException;
+use TYPO3\CMS\Core\Database\Connection;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\QueryBuilder;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException;
+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 if 'TYPO3_MODE' is 'FE'), the 'ses_anonymous' configuration option must be set to true.
+ */
+class DatabaseSessionBackend implements SessionBackendInterface
+{
+    /**
+     * @var array
+     */
+    protected $configuration = [];
+
+    /**
+     * @var bool Indicates whether the sessions table has the ses_anonymous column
+     */
+    protected $hasAnonymousSessions = false;
+
+    /**
+     * Initializes the session backend
+     *
+     * @param string $identifier Name of the session type, e.g. FE or BE
+     * @param array $configuration
+     * @internal To be used only by SessionManager
+     */
+    public function initialize(string $identifier, array $configuration)
+    {
+        $this->hasAnonymousSessions = (bool)$configuration['has_anonymous'];
+        $this->configuration = $configuration;
+    }
+
+    /**
+     * Checks if the configuration is valid
+     *
+     * @return bool
+     * @throws \InvalidArgumentException
+     * @internal To be used only by SessionManager
+     */
+    public function validateConfiguration(): bool
+    {
+        if (empty($this->configuration['table'])) {
+            throw new \InvalidArgumentException(
+                'The session backend "' . get_class($this) . '" needs a "table" configuration.',
+                1442996707
+            );
+        }
+        return true;
+    }
+
+    /**
+     * Read session data
+     *
+     * @param string $sessionId
+     * @return array Returns the session data
+     * @throws SessionNotFoundException
+     */
+    public function get(string $sessionId): array
+    {
+        $query = $this->getQueryBuilder();
+
+        $query->select('*')
+            ->from($this->configuration['table'])
+            ->where($query->expr()->eq('ses_id', $query->createNamedParameter($sessionId, \PDO::PARAM_STR)));
+
+        $result = $query->execute()->fetch();
+
+        if (!is_array($result)) {
+            throw new SessionNotFoundException(
+                'The session with identifier ' . $sessionId . ' was not found ',
+                1481885483
+            );
+        }
+        return $result;
+    }
+
+    /**
+     * Delete a session record
+     *
+     * @param string $sessionId
+     * @return bool true if the session was deleted, false it session could not be found
+     */
+    public function remove(string $sessionId): bool
+    {
+        $query = $this->getQueryBuilder();
+        $query->delete($this->configuration['table'])
+            ->where($query->expr()->eq('ses_id', $query->createNamedParameter($sessionId, \PDO::PARAM_STR)));
+
+        return (bool)$query->execute();
+    }
+
+    /**
+     * Write session data. This method prevents overriding existing session data.
+     * ses_id will always be set to $sessionId and overwritten if existing in $sessionData
+     * This method updates ses_tstamp automatically
+     *
+     * @param string $sessionId
+     * @param array $sessionData
+     * @return array The newly created session record.
+     * @throws SessionNotCreatedException
+     */
+    public function set(string $sessionId, array $sessionData): array
+    {
+        $sessionData['ses_id'] = $sessionId;
+        $sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time();
+
+        try {
+            $this->getConnection()->insert(
+                $this->configuration['table'],
+                $sessionData,
+                ['ses_data' => \PDO::PARAM_LOB]
+            );
+        } catch (DBALException $e) {
+            throw new SessionNotCreatedException('Session could not be written to database', 1481895005, $e);
+        }
+
+        return $sessionData;
+    }
+
+    /**
+     * Updates the session data.
+     * ses_id will always be set to $sessionId and overwritten if existing in $sessionData
+     * This method updates ses_tstamp automatically
+     *
+     * @param string $sessionId
+     * @param array $sessionData The session data to update. Data may be partial.
+     * @return array $sessionData The newly updated session record.
+     * @throws SessionNotUpdatedException
+     */
+    public function update(string $sessionId, array $sessionData): array
+    {
+        $sessionData['ses_id'] = $sessionId;
+        $sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time();
+
+        try {
+            // allow 0 records to be affected, happens when no columns where changed
+            $this->getConnection()->update(
+                $this->configuration['table'],
+                $sessionData,
+                ['ses_id' => $sessionId],
+                ['ses_data' => \PDO::PARAM_LOB]
+            );
+        } catch (DBALException $e) {
+            throw new SessionNotUpdatedException(
+                'Session with id ' . $sessionId . ' could not be updated',
+                1481889220,
+                $e
+            );
+        }
+        return $sessionData;
+    }
+
+    /**
+     * Garbage Collection
+     *
+     * @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds.
+     * @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, non-authenticated sessions are ignored.
+     */
+    public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0)
+    {
+        $query = $this->getQueryBuilder();
+
+        $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');
+        $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));
+            $query->execute();
+        }
+    }
+
+    /**
+     * List all sessions
+     *
+     * @return array Return a list of all user sessions. The list may be empty
+     */
+    public function getAll(): array
+    {
+        $query = $this->getQueryBuilder();
+        $query->select('*')->from($this->configuration['table']);
+        return $query->execute()->fetchAll();
+    }
+
+    /**
+     * @return QueryBuilder
+     */
+    protected function getQueryBuilder(): QueryBuilder
+    {
+        return $this->getConnection()->createQueryBuilder();
+    }
+
+    /**
+     * @return Connection
+     */
+    protected function getConnection(): Connection
+    {
+        return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->configuration['table']);
+    }
+}
diff --git a/typo3/sysext/core/Classes/Session/Backend/Exception/AbstractBackendException.php b/typo3/sysext/core/Classes/Session/Backend/Exception/AbstractBackendException.php
new file mode 100644 (file)
index 0000000..bbf4aa8
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+namespace TYPO3\CMS\Core\Session\Backend\Exception;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Exception;
+
+/**
+ * An abstract session backend exception, specific exceptions extend this.
+ */
+abstract class AbstractBackendException extends Exception
+{
+}
diff --git a/typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotCreatedException.php b/typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotCreatedException.php
new file mode 100644 (file)
index 0000000..91b7786
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Session\Backend\Exception;
+
+/*
+ * 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!
+ */
+
+/**
+ * Class SessionNotCreatedException
+ */
+class SessionNotCreatedException extends AbstractBackendException
+{
+}
diff --git a/typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotFoundException.php b/typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotFoundException.php
new file mode 100644 (file)
index 0000000..568a04f
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Session\Backend\Exception;
+
+/*
+ * 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!
+ */
+
+/**
+ * Class SessionNotFoundException
+ */
+class SessionNotFoundException extends AbstractBackendException
+{
+}
diff --git a/typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotUpdatedException.php b/typo3/sysext/core/Classes/Session/Backend/Exception/SessionNotUpdatedException.php
new file mode 100644 (file)
index 0000000..5370ae6
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Session\Backend\Exception;
+
+/*
+ * 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!
+ */
+
+/**
+ * Class SessionNotUpdatedException
+ */
+class SessionNotUpdatedException extends AbstractBackendException
+{
+}
diff --git a/typo3/sysext/core/Classes/Session/Backend/RedisSessionBackend.php b/typo3/sysext/core/Classes/Session/Backend/RedisSessionBackend.php
new file mode 100644 (file)
index 0000000..0745f44
--- /dev/null
@@ -0,0 +1,343 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Session\Backend;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Class RedisSessionBackend
+ *
+ * This session backend takes these optional configuration options: 'hostname' (default '127.0.0.1'),
+ * 'database' (default 0), 'port' (default 3679) and 'password' (no default value).
+ */
+class RedisSessionBackend implements SessionBackendInterface
+{
+
+    /**
+     * @var array
+     */
+    protected $configuration = [];
+
+    /**
+     * Indicates whether the server is connected
+     *
+     * @var bool
+     */
+    protected $connected = false;
+
+    /**
+     * Used as instance independent identifier
+     * (e.g. if multiple installations write into the same database)
+     *
+     * @var string
+     */
+    protected $applicationIdentifier = '';
+
+    /**
+     * Instance of the PHP redis class
+     *
+     * @var \Redis
+     */
+    protected $redis;
+
+    /**
+     * @var string
+     */
+    protected $identifier;
+
+    /**
+     * Initializes the session backend
+     *
+     * @param string $identifier Name of the session type, e.g. FE or BE
+     * @param array $configuration
+     * @internal To be used only by SessionManager
+     */
+    public function initialize(string $identifier, array $configuration)
+    {
+        $this->redis = new \Redis();
+
+        $this->configuration = $configuration;
+        $this->identifier = $identifier;
+        $this->applicationIdentifier = 'typo3_ses_'
+            . $identifier . '_'
+            . sha1($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey']) . '_';
+    }
+
+    /**
+     * Checks if the configuration is valid
+     *
+     * @throws \InvalidArgumentException
+     * @internal To be used only by SessionManager
+     */
+    public function validateConfiguration()
+    {
+        if (!extension_loaded('redis')) {
+            throw new \RuntimeException(
+                'The PHP extension "redis" must be installed and loaded in order to use the redis session backend.',
+                1481269826
+            );
+        }
+
+        if (isset($this->configuration['database'])) {
+            if (!is_int($this->configuration['database'])) {
+                throw new \InvalidArgumentException(
+                    'The specified database number is of type "' . gettype($this->configuration['database']) .
+                    '" but an integer is expected.',
+                    1481270871
+                );
+            }
+
+            if ($this->configuration['database'] < 0) {
+                throw new \InvalidArgumentException(
+                    'The specified database "' . $this->configuration['database'] . '" must be greater or equal than zero.',
+                    1481270923
+                );
+            }
+        }
+    }
+
+    /**
+     * Read session data
+     *
+     * @param string $sessionId
+     * @return array Returns the session data
+     * @throws SessionNotFoundException
+     */
+    public function get(string $sessionId): array
+    {
+        $this->initializeConnection();
+
+        $key = $this->getSessionKeyName($sessionId);
+        $rawData = $this->redis->get($key);
+
+        if ($rawData !== false) {
+            return json_decode(
+                $rawData,
+                true
+            );
+        }
+        throw new SessionNotFoundException('Session could not be fetched from redis', 1481885583);
+    }
+
+    /**
+     * Delete a session record
+     *
+     * @param string $sessionId
+     *
+     * @return bool
+     */
+    public function remove(string $sessionId): bool
+    {
+        $this->initializeConnection();
+
+        return $this->redis->delete($this->getSessionKeyName($sessionId)) >= 1;
+    }
+
+    /**
+     * Write session data. This method prevents overriding existing session data.
+     * ses_id will always be set to $sessionId and overwritten if existing in $sessionData
+     * This method updates ses_tstamp automatically
+     *
+     * @param string $sessionId
+     * @param array $sessionData
+     * @return array The newly created session record.
+     * @throws SessionNotCreatedException
+     */
+    public function set(string $sessionId, array $sessionData): array
+    {
+        $this->initializeConnection();
+        $sessionData['ses_id'] = $sessionId;
+        $sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time();
+
+        $key = $this->getSessionKeyName($sessionId);
+
+        // nx will not allow overwriting existing keys
+        $wasSet = $this->redis->set(
+            $key,
+            json_encode($sessionData),
+            ['nx']
+        );
+
+        if (!$wasSet) {
+            throw new SessionNotCreatedException('Session could not be written to Redis', 1481895647);
+        }
+
+        return $sessionData;
+    }
+
+    /**
+     * Updates the session data.
+     * ses_id will always be set to $sessionId and overwritten if existing in $sessionData
+     * This method updates ses_tstamp automatically
+     *
+     * @param string $sessionId
+     * @param array $sessionData The session data to update. Data may be partial.
+     * @return array $sessionData The newly updated session record.
+     * @throws SessionNotUpdatedException
+     */
+    public function update(string $sessionId, array $sessionData): array
+    {
+        try {
+            $sessionData = array_merge($this->get($sessionId), $sessionData);
+        } catch (SessionNotFoundException $e) {
+            throw new SessionNotUpdatedException('Cannot update non-existing record', 1484389971, $e);
+        }
+        $sessionData['ses_id'] = $sessionId;
+        $sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time();
+
+        $key = $this->getSessionKeyName($sessionId);
+        $wasSet = $this->redis->set($key, json_encode($sessionData));
+
+        if (!$wasSet) {
+            throw new SessionNotUpdatedException('Session could not be updated in Redis', 1481896383);
+        }
+
+        return $sessionData;
+    }
+
+    /**
+     * Garbage Collection
+     *
+     * @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds.
+     * @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, nothing is collected.
+     */
+    public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0)
+    {
+        foreach ($this->getAll() as $sessionRecord) {
+            if ($sessionRecord['ses_anonymous']) {
+                if ($maximumAnonymousLifetime > 0 && ($sessionRecord['ses_tstamp'] + $maximumAnonymousLifetime) < $GLOBALS['EXEC_TIME']) {
+                    $this->redis->delete($this->getSessionKeyName($sessionRecord['ses_id']));
+                }
+            } else {
+                if (($sessionRecord['ses_tstamp'] + $maximumLifetime) < $GLOBALS['EXEC_TIME']) {
+                    $this->redis->delete($this->getSessionKeyName($sessionRecord['ses_id']));
+                }
+            }
+        }
+    }
+
+    /**
+     * Initializes the redis backend
+     *
+     * @return void
+     * @throws \RuntimeException if access to redis with password is denied or if database selection fails
+     */
+    protected function initializeConnection()
+    {
+        if ($this->connected) {
+            return;
+        }
+
+        try {
+            $this->connected = $this->redis->pconnect(
+                $this->configuration['hostname'] ?? '127.0.0.1',
+                $this->configuration['port'] ?? 6379
+            );
+        } catch (\RedisException $e) {
+            GeneralUtility::sysLog(
+                'Could not connect to redis server.',
+                'core',
+                GeneralUtility::SYSLOG_SEVERITY_ERROR
+            );
+        }
+
+        if (!$this->connected) {
+            throw new \RuntimeException(
+                'Could not connect to redis server at ' . $this->configuration['hostname'] . ':' . $this->configuration['port'],
+                1482242961
+            );
+        }
+
+        if (isset($this->configuration['password'])
+            && $this->configuration['password'] !== ''
+            && !$this->redis->auth($this->configuration['password'])
+        ) {
+            throw new \RuntimeException(
+                'The given password was not accepted by the redis server.',
+                1481270961
+            );
+        }
+
+        if (isset($this->configuration['database'])
+            && $this->configuration['database'] > 0
+            && !$this->redis->select($this->configuration['database'])
+        ) {
+            throw new \RuntimeException(
+                'The given database "' . $this->configuration['database'] . '" could not be selected.',
+                1481270987
+            );
+        }
+    }
+
+    /**
+     * List all sessions
+     *
+     * @return array Return a list of all user sessions. The list may be empty.
+     */
+    public function getAll(): array
+    {
+        $this->initializeConnection();
+
+        $keys = [];
+        // Initialize our iterator to null, needed by redis->scan
+        $iterator = null;
+        $this->redis->setOption(\Redis::OPT_SCAN, (string)\Redis::SCAN_RETRY);
+        $pattern = $this->getSessionKeyName('*');
+        // retry when we get no keys back, redis->scan returns a chunk (array) of keys per iteration
+        while (($keyChunk = $this->redis->scan($iterator, $pattern)) !== false) {
+            foreach ($keyChunk as $key) {
+                $keys[] = $key;
+            }
+        }
+
+        $encodedSessions = $this->redis->getMultiple($keys);
+        if (!is_array($encodedSessions)) {
+            return [];
+        }
+
+        $sessions = [];
+        foreach ($encodedSessions as $session) {
+            if (is_string($session)) {
+                $decodedSession = json_decode($session, true);
+                if ($decodedSession) {
+                    $sessions[] = $decodedSession;
+                }
+            }
+        }
+
+        return $sessions;
+    }
+
+    /**
+     * @param string $sessionId
+     * @return string
+     */
+    protected function getSessionKeyName(string $sessionId): string
+    {
+        return $this->applicationIdentifier . $sessionId;
+    }
+
+    /**
+     * @return int
+     */
+    protected function getSessionTimeout(): int
+    {
+        return (int)($GLOBALS['TYPO3_CONF_VARS'][$this->identifier]['sessionTimeout'] ?? 86400);
+    }
+}
diff --git a/typo3/sysext/core/Classes/Session/Backend/SessionBackendInterface.php b/typo3/sysext/core/Classes/Session/Backend/SessionBackendInterface.php
new file mode 100644 (file)
index 0000000..2dad5aa
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Session\Backend;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException;
+
+/**
+ * Interface SessionBackendInterface
+ */
+interface SessionBackendInterface
+{
+    /**
+     * Initializes the session backend
+     *
+     * @param string $identifier Name of the session type, e.g. FE or BE
+     * @param array $configuration
+     * @internal To be used only by SessionManager
+     */
+    public function initialize(string $identifier, array $configuration);
+
+    /**
+     * Checks if the configuration is valid
+     *
+     * @throws \InvalidArgumentException
+     * @internal To be used only by SessionManager
+     */
+    public function validateConfiguration();
+
+    /**
+     * List all sessions
+     *
+     * @return array Return a list of all user sessions. The list may be empty.
+     */
+    public function getAll() : array;
+
+    /**
+     * Read session data
+     *
+     * @param string $sessionId
+     * @return array Returns the session data
+     * @throws SessionNotFoundException
+     */
+    public function get(string $sessionId) : array;
+
+    /**
+     * Delete a session record
+     *
+     * @param string $sessionId
+     * @return bool true if the session was deleted, false it session could not be found
+     */
+    public function remove(string $sessionId) : bool;
+
+    /**
+     * Write session data. This method prevents overriding existing session data.
+     * ses_id will always be set to $sessionId and overwritten if existing in $sessionData
+     * This method updates ses_tstamp automatically
+     *
+     * @param string $sessionId
+     * @param array $sessionData
+     * @return array The newly created session record.
+     * @throws SessionNotCreatedException
+     */
+    public function set(string $sessionId, array $sessionData) : array;
+
+    /**
+     * Updates the session data.
+     * ses_id will always be set to $sessionId and overwritten if existing in $sessionData
+     * This method updates ses_tstamp automatically
+     *
+     * @param string $sessionId
+     * @param array $sessionData The session data to update. Data may be partial.
+     * @return array $sessionData The newly updated session record.
+     * @throws SessionNotUpdatedException
+     */
+    public function update(string $sessionId, array $sessionData) : array;
+
+    /**
+     * Garbage Collection
+     *
+     * @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds.
+     * @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, nothing is collected.
+     */
+    public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0);
+}
diff --git a/typo3/sysext/core/Classes/Session/SessionManager.php b/typo3/sysext/core/Classes/Session/SessionManager.php
new file mode 100644 (file)
index 0000000..1d2f712
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Session;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
+use TYPO3\CMS\Core\SingletonInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Class SessionManager
+ *
+ * Example Configuration
+ *
+ * $GLOBALS['TYPO3_CONF_VARS']['SYS']['session'] => [
+ *     'BE' => [
+ *         'backend' => \TYPO3\CMS\Core\Session\Backend\FileSessionBackend::class,
+ *         'savePath' => '/var/www/t3sessionframework/data/'
+ *     ]
+ * ]
+ */
+class SessionManager implements SingletonInterface
+{
+    /**
+     * @var SessionBackendInterface[]
+     */
+    protected $sessionBackends = [];
+
+    /**
+     * Gets the currently running session backend for the given context
+     *
+     * @param string $identifier
+     * @return SessionBackendInterface
+     * @throws \InvalidArgumentException
+     */
+    public function getSessionBackend(string $identifier) : SessionBackendInterface
+    {
+        if (!isset($this->sessionBackends[$identifier])) {
+            if (!isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['session'][$identifier]) || !is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['session'][$identifier])) {
+                throw new \InvalidArgumentException('Session configuration for identifier ' . $identifier . ' was not found', 1482234750);
+            }
+            $configuration = $GLOBALS['TYPO3_CONF_VARS']['SYS']['session'][$identifier];
+
+            $sessionBackend = $this->createSessionBackendFromConfiguration($identifier, $configuration);
+
+            // Validates the session backend configuration and throws an exception if something's wrong
+            $sessionBackend->validateConfiguration();
+            $this->sessionBackends[$identifier] = $sessionBackend;
+        }
+        return $this->sessionBackends[$identifier];
+    }
+
+    /**
+     * Creates a session backend from configuration
+     *
+     * @param string $identifier the identifier
+     * @param array $configuration The session configuration array
+     * @return SessionBackendInterface
+     * @throws \InvalidArgumentException
+     */
+    protected function createSessionBackendFromConfiguration(string $identifier, array $configuration) : SessionBackendInterface
+    {
+        $className = $configuration['backend'];
+
+        if (!is_subclass_of($className, SessionBackendInterface::class)) {
+            throw new \InvalidArgumentException('Configured session backend ' . $className . ' does not implement ' . SessionBackendInterface::class, 1482235035);
+        }
+
+        $options = $configuration['options'] ?? [];
+
+        /** @var SessionBackendInterface $backend */
+        $backend = GeneralUtility::makeInstance($className);
+        $backend->initialize($identifier, $options);
+        return $backend;
+    }
+}
index ad6c2e0..1891113 100644 (file)
@@ -50,6 +50,21 @@ return [
                 'xlf' => \TYPO3\CMS\Core\Localization\Parser\XliffParser::class
             ]
         ],
+        'session' => [
+            'BE' => [
+                'backend' => \TYPO3\CMS\Core\Session\Backend\DatabaseSessionBackend::class,
+                'options' => [
+                    'table' => 'be_sessions'
+                ]
+            ],
+            'FE' => [
+                'backend' => \TYPO3\CMS\Core\Session\Backend\DatabaseSessionBackend::class,
+                'options' => [
+                    'table' => 'fe_sessions',
+                    'has_anonymous' => true,
+                ]
+            ]
+        ],
         'fileCreateMask' => '0664',                        // File mode mask for Unix file systems (when files are uploaded/created).
         'folderCreateMask' => '2775',                    // As above, but for folders.
         'createGroup' => '',                            // Group for newly created files and folders (Unix only). Group ownership can be changed on Unix file systems (see above). Set this if you want to change the group ownership of created files/folders to a specific group. This makes sense in all cases where the webserver is running with a different user/group as you do. Create a new group on your system and add you and the webserver user to the group. Now you can safely set the last bit in fileCreateMask/folderCreateMask to 0 (e.g. 770). Important: The user who is running your webserver needs to be a member of the group you specify here! Otherwise you might get some error messages.
@@ -1110,8 +1125,8 @@ return [
         'loginSecurityLevel' => '',        // See description for <a href="#BE-loginSecurityLevel">[BE][loginSecurityLevel]</a>. Default state for frontend is "normal". Alternative authentication services can implement higher levels if preferred. For example, "rsa" level uses RSA password encryption (only if the rsaauth extension is installed)
         'lifetime' => 0,        // Integer: positive. If >0 and the option permalogin is >=0, the cookie of FE users will have a lifetime of the number of seconds this value indicates. Otherwise it will be a session cookie (deleted when browser is shut down). Setting this value to 604800 will result in automatic login of FE users during a whole week, 86400 will keep the FE users logged in for a day.
         'sessionDataLifetime' => 86400,        // Integer: positive. If >0, the session data will timeout and be removed after the number of seconds given (86400 seconds represents 24 hours).
+        'maxSessionDataSize' => 10000,        // Integer: Setting (deprecated) the maximum size (bytes) of frontend session data stored in the table fe_session_data. Set to zero (0) means no limit, but this is not recommended since it also disables a check that session data is stored only if a confirmed cookie is set. @deprecated since TYPO3 v8, will be removed in TYPO3 v9
         'permalogin' => 0,        // <p>Integer:</p><dl><dt>-1</dt><dd>Permanent login for FE users disabled.</dd><dt>0</dt><dd>By default permalogin is disabled for FE users but can be enabled by a form control in the login form.</dd><dt>1</dt><dd>Permanent login is by default enabled but can be disabled by a form control in the login form.</dd><dt>2</dt><dd>Permanent login is forced to be enabled.// In any case, permanent login is only possible if <a href="#FE-lifetime">[FE][lifetime]</a> lifetime is > 0.</dd></dl>
-        'maxSessionDataSize' => 10000,        // Integer: Setting the maximum size (bytes) of frontend session data stored in the table fe_session_data. Set to zero (0) means no limit, but this is not recommended since it also disables a check that session data is stored only if a confirmed cookie is set.
         'cookieDomain' => '',        // Same as <a href="#SYS-cookieDomain">$TYPO3_CONF_VARS['SYS']['cookieDomain']</a> but only for FE cookies. If empty, $TYPO3_CONF_VARS['SYS']['cookieDomain'] value will be used.
         'cookieName' => 'fe_typo_user',        // String: Set the name for the cookie used for the front-end user session
         'defaultUserTSconfig' => '',        // String (textarea). Enter lines of default frontend user/group TSconfig.
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Breaking-70316-AbstractUserAuthenticationPropertiesAndMethodsDroppedAndChanged.rst b/typo3/sysext/core/Documentation/Changelog/master/Breaking-70316-AbstractUserAuthenticationPropertiesAndMethodsDroppedAndChanged.rst
new file mode 100644 (file)
index 0000000..b5e8a26
--- /dev/null
@@ -0,0 +1,49 @@
+.. include:: ../../Includes.txt
+
+========================================================================================
+Breaking: #70316 - AbstractUserAuthentication properties and methods dropped and changed
+========================================================================================
+
+See :issue:`70316`
+
+Description
+===========
+
+The property :php:`AbstractUserAuthentication::session_table` has been dropped.
+The property :php:`FrontendUserAuthentication::sessionDataTimestamp` has been dropped.
+The property :php:`FrontendUserAuthentication::sesData` has been moved to :php:`AbstractUserAuthentication::sessionData`
+and is protected now.
+
+The method :php:`FrontendUserAuthentication::fetchSessionData()` has been removed and its
+logic has been integrated into :php:`AbstractUserAuthentication::fetchUserSession()`.
+
+Impact
+======
+
+Accessing one of these properties will raise a PHP warning.
+Calling the method :php:`fetchSessionData` will cause a PHP fatal error.
+
+
+Affected Installations
+======================
+
+All extensions accessing these properties will most likely not work properly anymore.
+Extensions accessing the removed method will not work at all.
+
+
+Migration
+=========
+
+Use configuration from :php:`DatabaseSessionBackend` located in
+:php:`$GLOBALS['TYPO3_CONF_VARS]['SYS']['session'][/* Session Identifier */]['table']` or use
+:php:`AbstractUserAuthentication::loginType` to distinguish between FE or BE login types.
+
+Session data can be manipulated with the following methods in :php:`AbstractUserAuthentication`
+
+  * :php:`getSessionData`
+  * :php:`setSessionData`
+
+
+Calls to :php:`FrontendUserAuthentication::fetchSessionData()` can safely be removed.
+
+.. index:: PHP-API
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Deprecation-70316-FrontendBasketWithRecs.rst b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-70316-FrontendBasketWithRecs.rst
new file mode 100644 (file)
index 0000000..6d1ef1d
--- /dev/null
@@ -0,0 +1,36 @@
+.. include:: ../../Includes.txt
+
+===============================================
+Deprecation: #70316 - Frontend basket with recs
+===============================================
+
+See :issue:`70316`
+
+Description
+===========
+
+The TypoScriptFrontendController has a basic mechanism to automatically register session data if the GET/POST
+variable :code:`recs is given. This has been deprecated. This additionally obsoletes the configuration
+variable :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['maxSessionDataSize']` which has been deprecated, too.
+
+
+Impact
+======
+
+Handling baskets or other session data in :code:`recs` throws a deprecation warning.
+
+
+Affected Installations
+======================
+
+Some old extension like `tt_products` rely on this handling and should be adapted. Searching extensions
+for string :php:`recs` should reveal affected parts.
+
+
+Migration
+=========
+
+Use the session functions :php:`setKey` and :php:`getKey` of :php:`$GLOBALS['TSFE']->fe_user` directly to store session data
+like basket information from within the extension.
+
+.. index:: Frontend, PHP-API
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-70316-IntroduceSessionStorageFramework.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-70316-IntroduceSessionStorageFramework.rst
new file mode 100644 (file)
index 0000000..dee1179
--- /dev/null
@@ -0,0 +1,54 @@
+.. include:: ../../Includes.txt
+
+=====================================================
+Feature: #70316 - Introduce Session Storage Framework
+=====================================================
+
+See :issue:`70316`
+
+Description
+===========
+
+A new session storage framework has been introduced. The goal of this framework is to create interoperability
+between different session storages (called "backends") like database, file storage, Redis, etc.
+
+
+Impact
+======
+
+An integrator may configure session backends based on :php:`TYPO3_MODE`, which is either `BE` or `FE`.
+
+The following session backends are available by default:
+
+- :php:`\TYPO3\CMS\Core\Session\Backend\DatabaseSessionBackend`
+- :php:`\TYPO3\CMS\Core\Session\Backend\RedisSessionBackend`
+
+The default session backend for `BE` and `FE` is :php:`DatabaseSessionBackend` with `table` set to `fe_sessions` and `be_sessions` respectively.
+
+The configuration of the backend for each :php:`TYPO3_MODE` is stored within `SYS/session`:
+
+.. code-block:: php
+
+    'SYS' => [
+        'session' => [
+            'BE' => [
+                'backend' => \TYPO3\CMS\Core\Session\Backend\RedisSessionBackend::class,
+                'options' => [
+                    'hostname' => 'localhost',
+                    'database' => 2
+                ]
+            ],
+        ],
+    ],
+
+The :php:`DatabaseSessionBackend` requires a `table` as option. If the backend is used to holds non-authenticated
+sessions (default for `FE`), the `has_anonymous` option must be set to true.
+
+The :php:`RedisSessionBackend` requires a running PHP redis module (PHP extension "redis") and a running redis service.
+By default, a connection will be made to `hostname` 127.0.0.1 and `port` 3679. You may also specify a `database`
+number which to store the sessions in (default database is 0) and also a `password` for the connection.
+
+A developer may implement a custom session backend. To achieve this, the interface
+:php:`\TYPO3\CMS\Core\Session\Backend\SessionBackendInterface` has to be implemented.
+
+.. index:: PHP-API
diff --git a/typo3/sysext/core/Tests/Functional/Session/Backend/DatabaseSessionBackendTest.php b/typo3/sysext/core/Tests/Functional/Session/Backend/DatabaseSessionBackendTest.php
new file mode 100644 (file)
index 0000000..3809e2f
--- /dev/null
@@ -0,0 +1,244 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Tests\Functional\Session\Backend;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Session\Backend\DatabaseSessionBackend;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
+use TYPO3\Components\TestingFramework\Core\Functional\FunctionalTestCase;
+
+/**
+ * Test case
+ */
+class DatabaseSessionBackendTest extends FunctionalTestCase
+{
+    /**
+     * @var DatabaseSessionBackend
+     */
+    protected $subject;
+
+    /**
+     * @var array
+     */
+    protected $testSessionRecord = [
+        'ses_id' => 'randomSessionId',
+        'ses_name' => 'session_name',
+        'ses_userid' => 1,
+        // serialize(['foo' => 'bar', 'boo' => 'far'])
+        'ses_data' => 'a:2:{s:3:"foo";s:3:"bar";s:3:"boo";s:3:"far";}',
+    ];
+
+    /**
+     * Set configuration for DatabaseSessionBackend
+     */
+    protected function setUp()
+    {
+        parent::setUp();
+
+        $this->subject = new DatabaseSessionBackend();
+        $this->subject->initialize('default', [
+            'table' => 'fe_sessions',
+            'has_anonymous' => true,
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function canValidateSessionBackend()
+    {
+        $this->subject->validateConfiguration();
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::set
+     */
+    public function sessionDataIsStoredProperly()
+    {
+        $record = $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        $expected = array_merge($this->testSessionRecord, ['ses_tstamp' => $GLOBALS['EXEC_TIME']]);
+
+        $this->assertEquals($record, $expected);
+        $this->assertArraySubset($expected, $this->subject->get('randomSessionId'));
+    }
+
+    /**
+     * @test
+     */
+    public function anonymousSessionDataIsStoredProperly()
+    {
+        $record = $this->subject->set('randomSessionId', array_merge($this->testSessionRecord, ['ses_anonymous' => 1]));
+
+        $expected = array_merge($this->testSessionRecord, ['ses_anonymous' => 1, 'ses_tstamp' => $GLOBALS['EXEC_TIME']]);
+
+        $this->assertEquals($record, $expected);
+        $this->assertArraySubset($expected, $this->subject->get('randomSessionId'));
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::get
+     */
+    public function throwExceptionOnNonExistingSessionId()
+    {
+        $this->expectException(SessionNotFoundException::class);
+        $this->expectExceptionCode(1481885483);
+        $this->subject->get('IDoNotExist');
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::update
+     */
+    public function mergeSessionDataWithNewData()
+    {
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        $updateData = [
+            'ses_data' => serialize(['foo' => 'baz', 'idontwantto' => 'set the world on fire']),
+            'ses_tstamp' => $GLOBALS['EXEC_TIME']
+        ];
+        $expectedMergedData = array_merge($this->testSessionRecord, $updateData);
+        $this->subject->update('randomSessionId', $updateData);
+        $fetchedRecord = $this->subject->get('randomSessionId');
+        $this->assertArraySubset($expectedMergedData, $fetchedRecord);
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::set
+     */
+    public function existingSessionMustNotBeOverridden()
+    {
+        $this->expectException(SessionNotCreatedException::class);
+        $this->expectExceptionCode(1481895005);
+
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        $newData = array_merge($this->testSessionRecord, ['ses_data' => serialize(['foo' => 'baz', 'idontwantto' => 'set the world on fire'])]);
+        $this->subject->set('randomSessionId', $newData);
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::update
+     */
+    public function cannotChangeSessionId()
+    {
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        $newSessionId = 'newRandomSessionId';
+        $newData = $this->testSessionRecord;
+        $newData['ses_id'] = $newSessionId;
+
+        // old session id has to exist, no exception must be thrown at this point
+        $this->subject->get('randomSessionId');
+
+        // Change session id
+        $this->subject->update('randomSessionId', $newData);
+
+        // no session with key newRandomSessionId should exist
+        $this->expectException(SessionNotFoundException::class);
+        $this->expectExceptionCode(1481885483);
+        $this->subject->get('newRandomSessionId');
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::remove
+     */
+    public function sessionGetsDestroyed()
+    {
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        // Remove session
+        $this->assertTrue($this->subject->remove('randomSessionId'));
+
+        // Check if session was really removed
+        $this->expectException(SessionNotFoundException::class);
+        $this->expectExceptionCode(1481885483);
+        $this->subject->get('randomSessionId');
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::getAll
+     */
+    public function canLoadAllSessions()
+    {
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+        $this->subject->set('randomSessionId2', $this->testSessionRecord);
+
+        // Check if session was really removed
+        $this->assertEquals(2, count($this->subject->getAll()));
+    }
+
+    /**
+     * @test
+     */
+    public function canCollectGarbage()
+    {
+        $GLOBALS['EXEC_TIME'] = 150;
+        $authenticatedSession = array_merge($this->testSessionRecord, ['ses_id' => 'authenticatedSession']);
+        $anonymousSession = array_merge($this->testSessionRecord, ['ses_id' => 'anonymousSession', 'ses_anonymous' => 1]);
+
+        $this->subject->set('authenticatedSession', $authenticatedSession);
+        $this->subject->set('anonymousSession', $anonymousSession);
+
+        // Assert that we set authenticated session correctly
+        $this->assertArraySubset(
+            $authenticatedSession,
+            $this->subject->get('authenticatedSession')
+        );
+
+        // assert that we set anonymous session correctly
+        $this->assertArraySubset(
+            $anonymousSession,
+            $this->subject->get('anonymousSession'));
+
+        // Run the garbage collection
+        $GLOBALS['EXEC_TIME'] = 200;
+        // 150 + 10 < 200 but 150 + 60 >= 200
+        $this->subject->collectGarbage(60, 10);
+
+        // Authenticated session should still be there
+        $this->assertArraySubset(
+            $authenticatedSession,
+            $this->subject->get('authenticatedSession'));
+
+        // Non-authenticated session should be removed
+        $this->expectException(SessionNotFoundException::class);
+        $this->expectExceptionCode(1481885483);
+        $this->subject->get('anonymousSession');
+    }
+
+    /**
+     * @test
+     */
+    public function canPartiallyUpdateAfterGet()
+    {
+        $updatedRecord = array_merge(
+            $this->testSessionRecord,
+            ['ses_name' => 'newSessionName', 'ses_tstamp' => $GLOBALS['EXEC_TIME']]
+        );
+        $sessionId = 'randomSessionId';
+        $this->subject->set($sessionId, $this->testSessionRecord);
+        $this->subject->update($sessionId, ['ses_name' => 'newSessionName']);
+        $this->assertArraySubset($updatedRecord, $this->subject->get($sessionId));
+    }
+}
diff --git a/typo3/sysext/core/Tests/Functional/Session/Backend/RedisSessionBackendTest.php b/typo3/sysext/core/Tests/Functional/Session/Backend/RedisSessionBackendTest.php
new file mode 100644 (file)
index 0000000..a5a96c6
--- /dev/null
@@ -0,0 +1,274 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Tests\Functional\Session\Backend;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException;
+use TYPO3\CMS\Core\Session\Backend\RedisSessionBackend;
+use TYPO3\Components\TestingFramework\Core\Functional\FunctionalTestCase;
+
+/**
+ * Test case
+ */
+class RedisSessionBackendTest extends FunctionalTestCase
+{
+    /**
+     * @var RedisSessionBackend Prepared and connected redis test subject
+     */
+    protected $subject;
+
+    /**
+     * @var array
+     */
+    protected $testSessionRecord = [
+        'ses_id' => 'randomSessionId',
+        'ses_name' => 'session_name',
+        'ses_userid' => 1,
+        // serialize(['foo' => 'bar', 'boo' => 'far'])
+        'ses_data' => 'a:2:{s:3:"foo";s:3:"bar";s:3:"boo";s:3:"far";}',
+    ];
+
+    /**
+     * Set configuration for RedisSessionBackend
+     */
+    protected function setUp()
+    {
+        parent::setUp();
+
+        if (!extension_loaded('redis')) {
+            $this->markTestSkipped('redis extension was not available');
+        }
+        try {
+            if (!@fsockopen('127.0.0.1', 6379)) {
+                $this->markTestSkipped('redis server not reachable');
+            }
+        } catch (\Exception $e) {
+            $this->markTestSkipped('redis server not reachable');
+        }
+        $redis = new \Redis();
+        $redis->connect('127.0.0.1');
+        $redis->select(0);
+        // Clear db to ensure no sessions exist currently
+        $redis->flushDB();
+
+        $this->subject = new RedisSessionBackend();
+        $this->subject->initialize(
+            'default',
+            [
+                'database' => 0,
+                'port' => 6379,
+                'hostname' => 'localhost',
+            ]
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function cannotUpdateNonExistingRecord()
+    {
+        $this->expectException(SessionNotUpdatedException::class);
+        $this->expectExceptionCode(1484389971);
+        $this->subject->update('iSoNotExist', []);
+    }
+
+    /**
+     * @test
+     */
+    public function canValidateSessionBackend()
+    {
+        $this->subject->validateConfiguration();
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::set
+     */
+    public function sessionDataIsStoredProperly()
+    {
+        $record = $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        $expected = array_merge($this->testSessionRecord, ['ses_tstamp' => $GLOBALS['EXEC_TIME']]);
+
+        $this->assertEquals($record, $expected);
+        $this->assertArraySubset($expected, $this->subject->get('randomSessionId'));
+    }
+
+    /**
+     * @test
+     */
+    public function anonymousSessionDataIsStoredProperly()
+    {
+        $record = $this->subject->set('randomSessionId', array_merge($this->testSessionRecord, ['ses_anonymous' => 1]));
+
+        $expected = array_merge($this->testSessionRecord, ['ses_anonymous' => 1, 'ses_tstamp' => $GLOBALS['EXEC_TIME']]);
+
+        $this->assertEquals($record, $expected);
+        $this->assertArraySubset($expected, $this->subject->get('randomSessionId'));
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::get
+     */
+    public function throwExceptionOnNonExistingSessionId()
+    {
+        $this->expectException(SessionNotFoundException::class);
+        $this->expectExceptionCode(1481885583);
+        $this->subject->get('IDoNotExist');
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::update
+     */
+    public function mergeSessionDataWithNewData()
+    {
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        $updateData = [
+            'ses_data' => serialize(['foo' => 'baz', 'idontwantto' => 'set the world on fire']),
+            'ses_tstamp' => $GLOBALS['EXEC_TIME']
+        ];
+        $expectedMergedData = array_merge($this->testSessionRecord, $updateData);
+        $this->subject->update('randomSessionId', $updateData);
+        $fetchedRecord = $this->subject->get('randomSessionId');
+        $this->assertArraySubset($expectedMergedData, $fetchedRecord);
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::set
+     */
+    public function existingSessionMustNotBeOverridden()
+    {
+        $this->expectException(SessionNotCreatedException::class);
+        $this->expectExceptionCode(1481895647);
+
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        $newData = array_merge($this->testSessionRecord, ['ses_data' => serialize(['foo' => 'baz', 'idontwantto' => 'set the world on fire'])]);
+        $this->subject->set('randomSessionId', $newData);
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::update
+     */
+    public function cannotChangeSessionId()
+    {
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        $newSessionId = 'newRandomSessionId';
+        $newData = array_merge($this->testSessionRecord, ['ses_id' => $newSessionId]);
+
+        // old session id has to exist, no exception must be thrown at this point
+        $this->subject->get('randomSessionId');
+
+        // Change session id
+        $this->subject->update('randomSessionId', $newData);
+
+        // no session with key newRandomSessionId should exist
+        $this->expectException(SessionNotFoundException::class);
+        $this->expectExceptionCode(1481885583);
+        $this->subject->get('newRandomSessionId');
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::remove
+     */
+    public function sessionGetsDestroyed()
+    {
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+
+        // Remove session
+        $this->assertTrue($this->subject->remove('randomSessionId'));
+
+        // Check if session was really removed
+        $this->expectException(SessionNotFoundException::class);
+        $this->expectExceptionCode(1481885583);
+        $this->subject->get('randomSessionId');
+    }
+
+    /**
+     * @test
+     * @covers SessionBackendInterface::getAll
+     */
+    public function canLoadAllSessions()
+    {
+        $this->subject->set('randomSessionId', $this->testSessionRecord);
+        $this->subject->set('randomSessionId2', $this->testSessionRecord);
+
+        // Check if session was really removed
+        $this->assertEquals(2, count($this->subject->getAll()));
+    }
+
+    /**
+     * @test
+     */
+    public function canCollectGarbage()
+    {
+        $GLOBALS['EXEC_TIME'] = 150;
+        $authenticatedSession = array_merge($this->testSessionRecord, ['ses_id' => 'authenticatedSession']);
+        $anonymousSession = array_merge($this->testSessionRecord, ['ses_id' => 'anonymousSession', 'ses_anonymous' => 1]);
+
+        $this->subject->set('authenticatedSession', $authenticatedSession);
+        $this->subject->set('anonymousSession', $anonymousSession);
+
+        // Assert that we set authenticated session correctly
+        $this->assertArraySubset(
+            $authenticatedSession,
+            $this->subject->get('authenticatedSession')
+        );
+
+        // assert that we set anonymous session correctly
+        $this->assertArraySubset(
+            $anonymousSession,
+            $this->subject->get('anonymousSession'));
+
+        // Run the garbage collection
+        $GLOBALS['EXEC_TIME'] = 200;
+        // 150 + 10 < 200 but 150 + 60 >= 200
+        $this->subject->collectGarbage(60, 10);
+
+        // Authenticated session should still be there
+        $this->assertArraySubset(
+            $authenticatedSession,
+            $this->subject->get('authenticatedSession'));
+
+        // Non-authenticated session should be removed
+        $this->expectException(SessionNotFoundException::class);
+        $this->expectExceptionCode(1481885583);
+        $this->subject->get('anonymousSession');
+    }
+
+    /**
+     * @test
+     */
+    public function canPartiallyUpdateAfterGet()
+    {
+        $updatedRecord = array_merge(
+            $this->testSessionRecord,
+            ['ses_name' => 'newSessionName', 'ses_tstamp' => $GLOBALS['EXEC_TIME']]
+        );
+        $sessionId = 'randomSessionId';
+        $this->subject->set($sessionId, $this->testSessionRecord);
+        $this->subject->update($sessionId, ['ses_name' => 'newSessionName']);
+        $this->assertArraySubset($updatedRecord, $this->subject->get($sessionId));
+    }
+}
index 20d1dfc..a569a16 100644 (file)
@@ -21,14 +21,17 @@ use TYPO3\CMS\Core\Database\Connection;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder;
 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
+use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
+use TYPO3\CMS\Core\Resource\ResourceStorage;
 use TYPO3\CMS\Core\Tests\Unit\Database\Mocks\MockPlatform;
 use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
 
 /**
- * Testcase for BackendUserAuthentication
+ * Test case
  */
-class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase
+class BackendUserAuthenticationTest extends UnitTestCase
 {
     /**
      * @var array
@@ -53,15 +56,12 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
         'recursivedeleteFolder' => false
     ];
 
-    protected function setUp()
-    {
-        // reset hooks
-        $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'] = [];
-    }
-
+    /**
+     * Tear down
+     */
     protected function tearDown()
     {
-        \TYPO3\CMS\Core\FormProtection\FormProtectionFactory::purgeInstances();
+        FormProtectionFactory::purgeInstances();
         parent::tearDown();
     }
 
@@ -97,13 +97,12 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
         $formProtection = $this->prophesize(\TYPO3\CMS\Core\FormProtection\BackendFormProtection::class);
         $formProtection->clean()->shouldBeCalled();
 
-        \TYPO3\CMS\Core\FormProtection\FormProtectionFactory::set(
+        FormProtectionFactory::set(
             'default',
             $formProtection->reveal()
         );
 
-        // logoff() call the static factory that has a dependency to a valid BE_USER object. Mock this away
-        $GLOBALS['BE_USER'] = $this->createMock(BackendUserAuthentication::class);
+        $GLOBALS['BE_USER'] = $this->getMockBuilder(BackendUserAuthentication::class)->getMock();
         $GLOBALS['BE_USER']->user = ['uid' => $this->getUniqueId()];
 
         /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
@@ -111,6 +110,7 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
             ->setMethods(['dummy'])
             ->disableOriginalConstructor()
             ->getMock();
+
         $subject->logoff();
     }
 
@@ -261,6 +261,7 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function getTSConfigReturnsCorrectArrayForGivenObjectString(array $completeConfiguration, $objectString, array $expectedConfiguration)
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['dummy'])
             ->disableOriginalConstructor()
@@ -330,6 +331,7 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function getFilePermissionsTakesUserDefaultPermissionsFromTsConfigIntoAccountIfUserIsNotAdmin(array $userTsConfiguration)
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['isAdmin'])
             ->getMock();
@@ -467,10 +469,11 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function getFilePermissionsFromStorageOverwritesDefaultPermissions(array $defaultPermissions, $storageUid, array $storagePermissions, array $expectedPermissions)
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['isAdmin', 'getFilePermissions'])
             ->getMock();
-        $storageMock = $this->createMock(\TYPO3\CMS\Core\Resource\ResourceStorage::class);
+        $storageMock = $this->createMock(ResourceStorage::class);
         $storageMock->expects($this->any())->method('getUid')->will($this->returnValue($storageUid));
 
         $subject
@@ -505,10 +508,11 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function getFilePermissionsFromStorageAlwaysReturnsDefaultPermissionsForAdmins(array $defaultPermissions, $storageUid, array $storagePermissions)
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['isAdmin', 'getFilePermissions'])
             ->getMock();
-        $storageMock = $this->createMock(\TYPO3\CMS\Core\Resource\ResourceStorage::class);
+        $storageMock = $this->createMock(ResourceStorage::class);
         $storageMock->expects($this->any())->method('getUid')->will($this->returnValue($storageUid));
 
         $subject
@@ -645,10 +649,15 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
 
     /**
      * @test
+     *
+     * @param string $permissionValue
+     * @param array $expectedPermissions
+     *
      * @dataProvider getFilePermissionsTakesUserDefaultPermissionsFromRecordIntoAccountIfUserIsNotAdminDataProvider
      */
-    public function getFilePermissionsTakesUserDefaultPermissionsFromRecordIntoAccountIfUserIsNotAdmin($permissionValue, $expectedPermissions)
+    public function getFilePermissionsTakesUserDefaultPermissionsFromRecordIntoAccountIfUserIsNotAdmin(string $permissionValue, array $expectedPermissions)
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['isAdmin'])
             ->getMock();
@@ -668,6 +677,7 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function getFilePermissionsGrantsAllPermissionsToAdminUsers()
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['isAdmin'])
             ->getMock();
@@ -703,6 +713,7 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function jsConfirmationReturnsTrueIfPassedValueEqualsConfiguration()
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['getTSConfig'])
             ->getMock();
@@ -717,6 +728,7 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function jsConfirmationAllowsSettingMultipleBitsInValue()
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['getTSConfig'])
             ->getMock();
@@ -731,6 +743,7 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function jsConfirmationAlwaysReturnsFalseIfNoConfirmationIsSet()
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['getTSConfig'])
             ->getMock();
@@ -745,6 +758,7 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
      */
     public function jsConfirmationReturnsTrueIfConfigurationIsMissing()
     {
+        /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
         $subject = $this->getMockBuilder(BackendUserAuthentication::class)
             ->setMethods(['getTSConfig'])
             ->getMock();
@@ -810,23 +824,25 @@ class BackendUserAuthenticationTest extends \TYPO3\Components\TestingFramework\C
         // of GeneralUtility::addInstance will influence other tests
         // as the ConnectionPool is never used!
         if (!$admin) {
-            /** @var Connection|ObjectProphecy $connectionProphet */
-            $connectionProphet = $this->prophesize(Connection::class);
-            $connectionProphet->getDatabasePlatform()->willReturn(new MockPlatform());
-            $connectionProphet->quoteIdentifier(Argument::cetera())->will(function ($args) {
+            /** @var Connection|ObjectProphecy $connectionProphecy */
+            $connectionProphecy = $this->prophesize(Connection::class);
+            $connectionProphecy->getDatabasePlatform()->willReturn(new MockPlatform());
+            $connectionProphecy->quoteIdentifier(Argument::cetera())->will(function ($args) {
                 return '`' . str_replace('.', '`.`', $args[0]) . '`';
             });
 
-            /** @var QueryBuilder|ObjectProphecy $queryBuilderProphet */
-            $queryBuilderProphet = $this->prophesize(QueryBuilder::class);
-            $queryBuilderProphet->expr()->willReturn(
-                GeneralUtility::makeInstance(ExpressionBuilder::class, $connectionProphet->reveal())
+            /** @var QueryBuilder|ObjectProphecy $queryBuilderProphecy */
+            $queryBuilderProphecy = $this->prophesize(QueryBuilder::class);
+            $queryBuilderProphecy->expr()->willReturn(
+                new ExpressionBuilder($connectionProphecy->reveal())
             );
 
-            /** @var ConnectionPool|ObjectProphecy $databaseProphet */
-            $databaseProphet = $this->prophesize(ConnectionPool::class);
-            $databaseProphet->getQueryBuilderForTable('pages')->willReturn($queryBuilderProphet->reveal());
-            GeneralUtility::addInstance(ConnectionPool::class, $databaseProphet->reveal());
+            /** @var ConnectionPool|ObjectProphecy $databaseProphecy */
+            $databaseProphecy = $this->prophesize(ConnectionPool::class);
+            $databaseProphecy->getQueryBuilderForTable('pages')->willReturn($queryBuilderProphecy->reveal());
+            // Shift previously added instance
+            GeneralUtility::makeInstance(ConnectionPool::class);
+            GeneralUtility::addInstance(ConnectionPool::class, $databaseProphecy->reveal());
         }
 
         /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $subject */
index db4047c..a1b8d6e 100644 (file)
@@ -64,7 +64,6 @@ class RedisBackendTest extends \TYPO3\Components\TestingFramework\Core\Unit\Unit
     /**
      * Sets up the redis backend used for testing
      *
-     * @param array $backendOptions Options for the redis backend
      */
     protected function setUpBackend(array $backendOptions = [])
     {
diff --git a/typo3/sysext/core/Tests/Unit/Session/Backend/DatabaseSessionBackendTest.php b/typo3/sysext/core/Tests/Unit/Session/Backend/DatabaseSessionBackendTest.php
new file mode 100644 (file)
index 0000000..281a1b7
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Tests\Unit\Session\Backend;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Session\Backend\DatabaseSessionBackend;
+use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Test case
+ */
+class DatabaseSessionBackendTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function validateConfigurationThrowsExceptionIfTableNameIsMissingInConfiguration()
+    {
+        $subject = new DatabaseSessionBackend();
+        $subject->initialize('default', []);
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1442996707);
+        $subject->validateConfiguration();
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Session/Backend/RedisSessionBackendTest.php b/typo3/sysext/core/Tests/Unit/Session/Backend/RedisSessionBackendTest.php
new file mode 100644 (file)
index 0000000..795ba61
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Tests\Unit\Session\Backend;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Session\Backend\RedisSessionBackend;
+use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Test case
+ */
+class RedisSessionBackendTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function databaseConfigurationMustBeInteger()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1481270871);
+        $subject = new RedisSessionBackend();
+        $subject->initialize(
+            'default',
+            [
+                'database' => 'numberZero'
+            ]
+        );
+        $subject->validateConfiguration();
+    }
+
+    /**
+     * @test
+     */
+    public function databaseConfigurationMustBeZeroOrGreater()
+    {
+        $subject = new RedisSessionBackend();
+        $subject->initialize(
+            'default',
+            [
+                'database' => -1
+            ]
+        );
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1481270923);
+        $subject->validateConfiguration();
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Session/SessionManagerTest.php b/typo3/sysext/core/Tests/Unit/Session/SessionManagerTest.php
new file mode 100644 (file)
index 0000000..d85fe91
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Tests\Unit\Session;
+
+/*
+ * 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!
+ */
+
+use Prophecy\Argument;
+use TYPO3\CMS\Core\Session\Backend\DatabaseSessionBackend;
+use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
+use TYPO3\CMS\Core\Session\SessionManager;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Tests for the SessionManager
+ */
+class SessionManagerTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function getSessionBackendUsesDefaultBackendFromConfiguration()
+    {
+        $subject = new SessionManager();
+        $this->assertInstanceOf(DatabaseSessionBackend::class, $subject->getSessionBackend('BE'));
+    }
+
+    /**
+     * @test
+     */
+    public function getSessionBackendReturnsExpectedSessionBackendBasedOnConfiguration()
+    {
+        $backendProphecy = $this->prophesize(SessionBackendInterface::class);
+        $backendRevelation = $backendProphecy->reveal();
+        $backendClassName = get_class($backendRevelation);
+        $GLOBALS['TYPO3_CONF_VARS']['SYS']['session']['myidentifier'] = [
+            'backend'  => $backendClassName,
+            'options' => []
+        ];
+        $backendProphecy->initialize(Argument::cetera())->shouldBeCalled();
+        $backendProphecy->validateConfiguration(Argument::cetera())->shouldBeCalled();
+        GeneralUtility::addInstance($backendClassName, $backendRevelation);
+        $subject = new SessionManager();
+        $this->assertInstanceOf($backendClassName, $subject->getSessionBackend('myidentifier'));
+    }
+
+    /**
+     * @test
+     */
+    public function getSessionBackendThrowsExceptionForMissingConfiguration()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1482234750);
+        $GLOBALS['TYPO3_CONF_VARS']['SYS']['session']['myNewIdentifier'] = 'I am not an array';
+        $subject = new SessionManager();
+        $subject->getSessionBackend('myNewidentifier');
+    }
+
+    /**
+     * @test
+     */
+    public function getSessionBackendThrowsExceptionIfBackendDoesNotImplementInterface()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1482235035);
+        $GLOBALS['TYPO3_CONF_VARS']['SYS']['session']['myidentifier'] = [
+            'backend'  => \stdClass::class,
+            'options' => []
+        ];
+        (new SessionManager())->getSessionBackend('myidentifier');
+    }
+}
index 4e874f6..f10d086 100644 (file)
@@ -15,8 +15,7 @@ namespace TYPO3\CMS\Frontend\Authentication;
  */
 
 use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
-use TYPO3\CMS\Core\Database\Connection;
-use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
 use TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
@@ -85,20 +84,6 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
     public $userTSUpdated = false;
 
     /**
-     * Session and user data:
-     * There are two types of data that can be stored: UserData and Session-Data.
-     * Userdata is for the login-user, and session-data for anyone viewing the pages.
-     * 'Keys' are keys in the internal data array of the data.
-     * When you get or set a key in one of the data-spaces (user or session) you decide the type of the variable (not object though)
-     * 'Reserved' keys are:
-     *   - 'recs': Array: Used to 'register' records, eg in a shopping basket. Structure: [recs][tablename][record_uid]=number
-     *   - sys: Reserved for TypoScript standard code.
-     *
-     * @var array
-     */
-    public $sesData = [];
-
-    /**
      * @var bool
      */
     public $sesData_change = false;
@@ -114,11 +99,6 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
     public $is_permanent = false;
 
     /**
-     * @var int|NULL
-     */
-    protected $sessionDataTimestamp = null;
-
-    /**
      * @var bool
      */
     protected $loginHidden = false;
@@ -134,7 +114,6 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
         // a user is logging-in or an existing session is found
         $this->dontSetCookie = true;
 
-        $this->session_table = 'fe_sessions';
         $this->name = self::getCookieName();
         $this->get_name = 'ftu';
         $this->loginType = 'FE';
@@ -215,7 +194,7 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
     public function isSetSessionCookie()
     {
         return ($this->newSessionID || $this->forceSetCookie)
-            && ($this->lifetime == 0 || !isset($this->user['ses_permanent']) || !$this->user['ses_permanent']);
+            && ((int)$this->lifetime === 0 || !isset($this->user['ses_permanent']) || !$this->user['ses_permanent']);
     }
 
     /**
@@ -272,7 +251,7 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
      */
     public function createUserSession($tempuser)
     {
-        // At this point we do not know if we need to set a session or a "permanant" cookie
+        // At this point we do not know if we need to set a session or a permanent cookie
         // So we force the cookie to be set after authentication took place, which will
         // then call setSessionCookie(), which will set a cookie with correct settings.
         $this->dontSetCookie = false;
@@ -396,79 +375,43 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
      *
      ****************************************/
     /**
-     * Fetches the session data for the user (from the fe_session_data table) based on the ->id of the current user-session.
-     * The session data is restored to $this->sesData
-     * 1/100 calls will also do a garbage collection.
-     *
-     * @return void
-     * @access private
-     * @see storeSessionData()
-     */
-    public function fetchSessionData()
-    {
-        // Gets SesData if any AND if not already selected by session fixation check in ->isExistingSessionRecord()
-        if ($this->id && empty($this->sesData)) {
-            $sesDataRow = GeneralUtility::makeInstance(ConnectionPool::class)
-                ->getConnectionForTable('fe_session_data')->select(
-                    ['*'],
-                    'fe_session_data',
-                    ['hash' => $this->id]
-                )->fetch();
-            if ($sesDataRow !== null) {
-                $this->sesData = unserialize($sesDataRow['content']);
-                $this->sessionDataTimestamp = $sesDataRow['tstamp'];
-            }
-        }
-    }
-
-    /**
      * Will write UC and session data.
      * If the flag $this->userData_change has been set, the function ->writeUC is called (which will save persistent user session data)
-     * If the flag $this->sesData_change has been set, the fe_session_data table is updated with the content of $this->sesData
-     * If the $this->sessionDataTimestamp is NULL there was no session record yet, so we need to insert it into the database
+     * If the flag $this->sesData_change has been set, the current session record is updated with the content of $this->sessionData
      *
      * @return void
-     * @see fetchSessionData(), getKey(), setKey()
+     * @see getKey(), setKey()
      */
     public function storeSessionData()
     {
         // Saves UC and SesData if changed.
         if ($this->userData_change) {
-            $this->writeUC('');
+            $this->writeUC();
         }
-        $databaseConnection = GeneralUtility::makeInstance(ConnectionPool::class)
-            ->getConnectionForTable('fe_session_data');
+
         if ($this->sesData_change && $this->id) {
-            if (empty($this->sesData)) {
+            if (empty($this->sessionData)) {
                 // Remove session-data
                 $this->removeSessionData();
                 // Remove cookie if not logged in as the session data is removed as well
                 if (empty($this->user['uid']) && !$this->loginHidden && $this->isCookieSet()) {
                     $this->removeCookie($this->name);
                 }
-            } elseif ($this->sessionDataTimestamp === null) {
-                // Write new session-data
-                $insertFields = [
-                    'hash' => $this->id,
-                    'content' => serialize($this->sesData),
-                    'tstamp' => $GLOBALS['EXEC_TIME']
-                ];
-                $this->sessionDataTimestamp = $GLOBALS['EXEC_TIME'];
-                $databaseConnection->insert(
-                    'fe_session_data',
-                    $insertFields,
-                    ['content' => Connection::PARAM_LOB]
-                );
+            } elseif (!$this->isExistingSessionRecord($this->id)) {
+                $sessionRecord = $this->getNewSessionRecord([]);
+                $sessionRecord['ses_anonymous'] = 1;
+                $sessionRecord['ses_data'] = serialize($this->sessionData);
+                $updatedSession = $this->getSessionBackend()->set($this->id, $sessionRecord);
+                $this->user = array_merge($this->user ?? [], $updatedSession);
                 // Now set the cookie (= fix the session)
                 $this->setSessionCookie();
             } else {
                 // Update session data
-                $updateFields = [
-                    'content' => serialize($this->sesData),
-                    'tstamp' => $GLOBALS['EXEC_TIME']
+                $insertFields = [
+                    'ses_data' => serialize($this->sessionData)
                 ];
-                $this->sessionDataTimestamp = $GLOBALS['EXEC_TIME'];
-                $databaseConnection->update('fe_session_data', $updateFields, ['hash' => $this->id]);
+                $updatedSession = $this->getSessionBackend()->update($this->id, $insertFields);
+                $this->user = array_merge($this->user ?? [], $updatedSession);
             }
         }
     }
@@ -480,70 +423,70 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
      */
     public function removeSessionData()
     {
-        $this->sessionDataTimestamp = null;
-        GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('fe_session_data')
-            ->delete(
-                'fe_session_data',
-                ['hash' => $this->id]
-            );
+        if (!empty($this->sessionData)) {
+            $this->sesData_change = true;
+        }
+        $this->sessionData = [];
+
+        if ($this->isExistingSessionRecord($this->id)) {
+            // Remove session record if $this->user is empty is if session is anonymous
+            if ((empty($this->user) && !$this->loginHidden) || $this->user['ses_anonymous']) {
+                $this->getSessionBackend()->remove($this->id);
+            } else {
+                $this->getSessionBackend()->update($this->id, [
+                    'ses_data' => ''
+                ]);
+            }
+        }
     }
 
     /**
-     * Log out current user!
-     * Removes the current session record, sets the internal ->user array to a blank string
+     * Removes the current session record, sets the internal ->user array to null,
      * Thereby the current user (if any) is effectively logged out!
-     * Additionally the cookie is removed
+     * Additionally the cookie is removed, but only if there is no session data.
+     * If session data exists, only the user information is removed and the session
+     * gets converted into an anonymous session.
      *
      * @return void
      */
-    public function logoff()
+    protected function performLogoff()
     {
-        parent::logoff();
-        // Remove the cookie on log-off, but only if we do not have an anonymous session
-        if (!$this->isExistingSessionRecord($this->id) && $this->isCookieSet()) {
-            $this->removeCookie($this->name);
+        $oldSession = [];
+        $sessionData = [];
+        try {
+            // Session might not be loaded at this point, so fetch it
+            $oldSession = $this->getSessionBackend()->get($this->id);
+            $sessionData = unserialize($oldSession['ses_data']);
+        } catch (SessionNotFoundException $e) {
+            // Leave uncaught, will unset cookie later in this method
         }
-    }
 
-    /**
-     * Regenerate the id, take separate session data table into account
-     * and set cookie again
-     */
-    protected function regenerateSessionId()
-    {
-        $oldSessionId = $this->id;
-        parent::regenerateSessionId();
-        // Update session data with new ID
-        GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('fe_session_data')
-            ->update(
-                'fe_session_data',
-                ['hash' => $this->id],
-                ['hash' => $oldSessionId]
-            );
-
-        // We force the cookie to be set later in the authentication process
-        $this->dontSetCookie = false;
+        if (!empty($sessionData)) {
+            // Regenerate session as anonymous
+            $this->regenerateSessionId($oldSession, true);
+        } else {
+            $this->user = null;
+            $this->getSessionBackend()->remove($this->id);
+            if ($this->isCookieSet()) {
+                $this->removeCookie($this->name);
+            }
+        }
     }
 
     /**
-     * Executes the garbage collection of session data and session.
-     * The lifetime of session data is defined by $TYPO3_CONF_VARS['FE']['sessionDataLifetime'].
+     * Regenerate the session ID and transfer the session to new ID
+     * Call this method whenever a user proceeds to a higher authorization level
+     * e.g. when an anonymous session is now authenticated.
+     * Forces cookie to be set
      *
-     * @return void
+     * @param array $existingSessionRecord If given, this session record will be used instead of fetching again'
+     * @param bool $anonymous If true session will be regenerated as anonymous session
      */
-    public function gc()
+    protected function regenerateSessionId(array $existingSessionRecord = [], bool $anonymous = false)
     {
-        $timeoutTimeStamp = (int)($GLOBALS['EXEC_TIME'] - $this->sessionDataLifetime);
-        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('fe_session_data');
-        $queryBuilder->delete('fe_session_data')
-            ->where(
-                $queryBuilder->expr()->lt(
-                    'tstamp',
-                    $queryBuilder->createNamedParameter($timeoutTimeStamp, \PDO::PARAM_INT)
-                )
-            )
-            ->execute();
-        parent::gc();
+        parent::regenerateSessionId($existingSessionRecord, $anonymous);
+        // We force the cookie to be set later in the authentication process
+        $this->dontSetCookie = false;
     }
 
     /**
@@ -551,7 +494,7 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
      * or current-session based (not available when browse is closed, but does not require login)
      *
      * @param string $type Session data type; Either "user" (persistent, bound to fe_users profile) or "ses" (temporary, bound to current session cookie)
-     * @param string $key Key from the data array to return; The session data (in either case) is an array ($this->uc / $this->sesData) and this value determines which key to return the value for.
+     * @param string $key Key from the data array to return; The session data (in either case) is an array ($this->uc / $this->sessionData) and this value determines which key to return the value for.
      * @return mixed Returns whatever value there was in the array for the key, $key
      * @see setKey()
      */
@@ -566,7 +509,7 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
                 $value = $this->uc[$key];
                 break;
             case 'ses':
-                $value = $this->sesData[$key];
+                $value = $this->getSessionData($key);
                 break;
         }
         return $value;
@@ -579,7 +522,7 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
      * Notice: Simply calling this function will not save the data to the database! The actual saving is done in storeSessionData() which is called as some of the last things in \TYPO3\CMS\Frontend\Http\RequestHandler. So if you exit before this point, nothing gets saved of course! And the solution is to call $GLOBALS['TSFE']->storeSessionData(); before you exit.
      *
      * @param string $type Session data type; Either "user" (persistent, bound to fe_users profile) or "ses" (temporary, bound to current session cookie)
-     * @param string $key Key from the data array to store incoming data in; The session data (in either case) is an array ($this->uc / $this->sesData) and this value determines in which key the $data value will be stored.
+     * @param string $key Key from the data array to store incoming data in; The session data (in either case) is an array ($this->uc / $this->sessionData) and this value determines in which key the $data value will be stored.
      * @param mixed $data The data value to store in $key
      * @return void
      * @see setKey(), storeSessionData(), record_registration()
@@ -601,26 +544,27 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
                 }
                 break;
             case 'ses':
-                if ($data === null) {
-                    unset($this->sesData[$key]);
-                } else {
-                    $this->sesData[$key] = $data;
-                }
-                $this->sesData_change = true;
+                $this->setSessionData($key, $data);
                 break;
         }
     }
 
     /**
-     * Returns the session data stored for $key.
-     * The data will last only for this login session since it is stored in the session table.
+     * Set session data by key.
+     * The data will last only for this login session since it is stored in the user session.
      *
-     * @param string $key
-     * @return mixed
+     * @param string $key A non empty string to store the data under
+     * @param mixed $data Data store store in session
+     * @return void
      */
-    public function getSessionData($key)
+    public function setSessionData($key, $data)
     {
-        return $this->getKey('ses', $key);
+        $this->sesData_change = true;
+        if ($data === null) {
+            unset($this->sessionData[$key]);
+            return;
+        }
+        parent::setSessionData($key, $data);
     }
 
     /**
@@ -632,7 +576,7 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
      */
     public function setAndSaveSessionData($key, $data)
     {
-        $this->setKey('ses', $key, $data);
+        $this->setSessionData($key, $data);
         $this->storeSessionData();
     }
 
@@ -644,9 +588,11 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
      * @param array $recs The data array to merge into/override the current recs values. The $recs array is constructed as [table]][uid] = scalar-value (eg. string/integer).
      * @param int $maxSizeOfSessionData The maximum size of stored session data. If zero, no limit is applied and even confirmation of cookie session is discarded.
      * @return void
+     * @deprecated since TYPO3 v8, will be removed in TYPO3 v9. Automatically feeding a "basket" by magic GET/POST keyword "recs" has been deprecated.
      */
     public function record_registration($recs, $maxSizeOfSessionData = 0)
     {
+        GeneralUtility::logDeprecatedFunction();
         // Storing value ONLY if there is a confirmed cookie set,
         // otherwise a shellscript could easily be spamming the fe_sessions table
         // with bogus content and thus bloat the database
@@ -673,30 +619,14 @@ class FrontendUserAuthentication extends AbstractUserAuthentication
     }
 
     /**
-     * Determine whether there's an according session record to a given session_id
-     * in the database. Don't care if session record is still valid or not.
+     * Garbage collector, removing old expired sessions.
      *
-     * This calls the parent function but additionally tries to look up the session ID in the "fe_session_data" table.
-     *
-     * @param int $id Claimed Session ID
-     * @return bool Returns TRUE if a corresponding session was found in the database
+     * @return void
+     * @internal
      */
-    public function isExistingSessionRecord($id)
+    public function gc()
     {
-        // Perform check in parent function
-        $count = parent::isExistingSessionRecord($id);
-        // Check if there are any fe_session_data records for the session ID the client claims to have
-        if ($count == false) {
-            $sesDataRow = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('fe_session_data')
-                ->select(['content', 'tstamp'], 'fe_session_data', ['hash' => $id])->fetch();
-
-            if ($sesDataRow !== null) {
-                $count = true;
-                $this->sesData = unserialize($sesDataRow['content']);
-                $this->sessionDataTimestamp = $sesDataRow['tstamp'];
-            }
-        }
-        return $count;
+        $this->getSessionBackend()->collectGarbage($this->gc_time, $this->sessionDataLifetime);
     }
 
     /**
index a41075a..73b52fd 100644 (file)
@@ -1013,13 +1013,16 @@ class TypoScriptFrontendController
         }
         $this->fe_user->start();
         $this->fe_user->unpack_uc();
-        // Gets session data
-        $this->fe_user->fetchSessionData();
+
+        // @deprecated since TYPO3 v8, will be removed in TYPO3 v9
+        // @todo: With the removal of that in v9, TYPO3_CONF_VARS maxSessionDataSize can be removed as well,
+        // @todo: and a silent ugrade wizard to remove the setting from LocalConfiguration should be added.
         $recs = GeneralUtility::_GP('recs');
-        // If any record registration is submitted, register the record.
         if (is_array($recs)) {
+            // If any record registration is submitted, register the record.
             $this->fe_user->record_registration($recs, $GLOBALS['TYPO3_CONF_VARS']['FE']['maxSessionDataSize']);
         }
+
         // Call hook for possible manipulation of frontend user object
         if (is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['initFEuser'])) {
             $_params = ['pObj' => &$this];
diff --git a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php
new file mode 100644 (file)
index 0000000..070d908
--- /dev/null
@@ -0,0 +1,433 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Frontend\Tests\Unit\Authentication;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
+use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
+use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
+use TYPO3\CMS\Sv\AuthenticationService;
+use TYPO3\Components\TestingFramework\Core\AccessibleObjectInterface;
+use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Test cases for FrontendUserAuthentication
+ */
+class FrontendUserAuthenticationTest extends UnitTestCase
+{
+
+    /** @var FrontendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject|AccessibleObjectInterface */
+    protected $subject;
+
+    /**
+     * Sets up FrontendUserAuthentication mock
+     */
+    protected function setUp()
+    {
+        parent::setUp();
+        $this->subject = $this->getMockBuilder($this->buildAccessibleProxy(FrontendUserAuthentication::class))
+            ->setMethods([
+                'getSessionBackend',
+                'createSessionId',
+                'getCookie',
+                'ipLockClause_remoteIPNumber',
+                'hashLockClause_getHashInt',
+                'getLoginFormData',
+                'getRawUserByUid',
+                'getAuthInfoArray',
+                'setSessionCookie',
+                'removeCookie',
+                'getAuthServices',
+                'createUserSession',
+                'updateLoginTimestamp'
+            ])
+            ->getMock();
+
+        $this->subject->method('getAuthInfoArray')->willReturn([]);
+
+        $this->subject->method('getRawUserByUid')->willReturn([
+            'uid' => 1,
+            'username' => 'existingUserName',
+            'password' => 'abc',
+            'deleted' => 0,
+            'disabled' => 0
+        ])->with(1);
+
+        $this->subject->method('ipLockClause_remoteIPNumber')->willReturn(0);
+        $this->subject->method('hashLockClause_getHashInt')->willReturn(0);
+    }
+
+    /**
+     * user properties should not be set for anonymous sessions
+     *
+     * @test
+     */
+    public function userFieldsIsNotSetForAnonymousSessions()
+    {
+        // Mock SessionBackend
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+        $oldSessionRecord = [
+            'ses_id' => 'oldSessionId',
+            'ses_data' => serialize(['foo' => 'bar']),
+            'ses_anonymous' => true,
+            'ses_iplock' => 0,
+        ];
+        $sessionBackend->method('get')->willReturn($oldSessionRecord);
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        $this->subject->expects($this->never())->method('createSessionId');
+
+        // Load anonymous sessions
+        $this->subject->method('getCookie')->willReturn('oldSessionId');
+
+        $this->subject->start();
+        $this->assertArrayNotHasKey('uid', $this->subject->user);
+        $this->assertEquals(['foo' => 'bar'], $this->subject->_get('sessionData'));
+        $this->assertEquals('oldSessionId', $this->subject->id);
+    }
+
+    /**
+     * @test
+     */
+    public function storeSessionDataOnAnonymousUserWithNoData()
+    {
+        // Mock SessionBackend
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        $this->subject->method('createSessionId')->willReturn('newSessionId');
+
+        $sessionBackend->expects($this->never())->method('set');
+        $sessionBackend->expects($this->never())->method('update');
+
+        $this->subject->start();
+        $this->subject->storeSessionData();
+    }
+
+    /**
+     * Setting and immediately removing session data should be handled correctly.
+     * No write operations should be made
+     *
+     * @test
+     */
+    public function canSetAndUnsetSessionKey()
+    {
+        // Mock SessionBackend
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        $this->subject->method('createSessionId')->willReturn('newSessionId');
+
+        $sessionBackend->expects($this->never())->method('set');
+        $sessionBackend->expects($this->never())->method('update');
+
+        $this->subject->start();
+        $this->subject->setSessionData('foo', 'bar');
+        $this->subject->removeSessionData();
+        $this->assertAttributeEmpty('sessionData', $this->subject);
+        $this->subject->storeSessionData();
+    }
+
+    /**
+     * A user that is not signed in should be able to have associated session data
+     *
+     * @test
+     */
+    public function canSetSessionDataForAnonymousUser()
+    {
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+        // Mock SessionBackend
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+
+        $this->subject->method('createSessionId')->willReturn('newSessionId');
+
+        $expectedSessionRecord = [
+            'ses_anonymous' => 1,
+            'ses_data' => serialize(['foo' => 'bar'])
+        ];
+
+        $sessionBackend->expects($this->any())->method('get');
+        $sessionBackend->expects($this->once())->method('set')->with('newSessionId', new \PHPUnit_Framework_Constraint_ArraySubset($expectedSessionRecord));
+
+        $this->subject->start();
+        $this->assertEmpty($this->subject->_get('sessionData'));
+        $this->assertEmpty($this->subject->user);
+        $this->subject->setSessionData('foo', 'bar');
+        $this->assertAttributeNotEmpty('sessionData', $this->subject);
+        $this->subject->storeSessionData();
+    }
+
+    /**
+     * Session data should be loaded when a session cookie is available and user user is authenticated
+     *
+     * @test
+     */
+    public function canLoadExistingAuthenticatedSession()
+    {
+        // Mock SessionBackend
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+        $sessionBackend->method('get')->willReturn(
+            [
+                'ses_id' => 'existingId',
+                'ses_userid' => 1, // fe_user with uid 0 assumed in database, see fixtures.xml
+                'ses_data' => serialize(['foo' => 'bar']),
+                'ses_iplock' => 0,
+                'ses_tstamp' => time() + 100 // Return a time in future to make avoid mocking $GLOBALS['EXEC_TIME']
+            ]
+        );
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        $this->subject->method('getCookie')->willReturn('existingId');
+
+        $this->subject->start();
+        $this->assertFalse($this->subject->_get('loginFailure'));
+        $this->assertAttributeNotEmpty('user', $this->subject);
+        $this->assertEquals('existingUserName', $this->subject->user['username']);
+    }
+
+    /**
+     * @test
+     */
+    public function canLogUserInWithoutAnonymousSession()
+    {
+        // Mock SessionBackend
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+
+        $sessionBackend->expects($this->at(0))->method('get')->willThrowException(new SessionNotFoundException('testing', 1486163180));
+
+        // Mock a login attempt
+        $this->subject->method('getLoginFormData')->willReturn([
+            'status' => 'login',
+            'uname' => 'existingUserName',
+            'uident' => 'abc'
+        ]);
+        $this->subject->method('createSessionId')->willReturn('newSessionId');
+
+        $authServiceMock = $this->getMockBuilder(AuthenticationService::class)->getMock();
+        $authServiceMock->method('getUser')->willReturn([
+            'uid' => 1,
+            'username' => 'existingUserName'
+        ]);
+
+        $authServiceMock->method('authUser')->willReturn(true); // Auth services can return true or 200
+
+        // We need to wrap the array to something thats is \Traversable, in PHP 7.1 we can use traversable pseudo type instead
+        $this->subject->method('getAuthServices')->willReturn(new \ArrayIterator([$authServiceMock]));
+
+        $this->subject->method('createUserSession')->willReturn([
+            'ses_id' => 'newSessionId'
+        ]);
+
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        $this->subject->method('getCookie')->willReturn(null);
+
+        $this->subject->start();
+        $this->assertFalse($this->subject->_get('loginFailure'));
+        $this->assertEquals('existingUserName', $this->subject->user['username']);
+    }
+
+    /**
+     * Session data set before a user is signed in should be preserved when signing in
+     *
+     * @test
+     */
+    public function canPreserveSessionDataWhenAuthenticating()
+    {
+        // Mock SessionBackend
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+
+        $oldSessionRecord = [
+            'ses_id' => 'oldSessionId',
+            'ses_data' => serialize(['foo' => 'bar']),
+            'ses_anonymous' => 1,
+            'ses_iplock' => 0,
+        ];
+
+        // Return old, non authenticated session
+        $sessionBackend->method('get')->willReturn($oldSessionRecord);
+
+        $expectedSessionRecord = array_merge(
+            $oldSessionRecord,
+            [
+                //ses_id is overwritten by the session backend
+                'ses_anonymous' => 0
+            ]
+        );
+
+        $expectedUserId = 1;
+
+        $sessionBackend->expects($this->once())->method('set')->with(
+            'newSessionId',
+            $this->equalTo($expectedSessionRecord)
+        )->willReturnArgument(1);
+
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        // Load old sessions
+        $this->subject->method('getCookie')->willReturn('oldSessionId');
+        $this->subject->method('createSessionId')->willReturn('newSessionId');
+
+        // Mock a login attempt
+        $this->subject->method('getLoginFormData')->willReturn([
+            'status' => 'login',
+            'uname' => 'existingUserName',
+            'uident' => 'abc'
+        ]);
+
+        $authServiceMock = $this->getMockBuilder(AuthenticationService::class)->getMock();
+        $authServiceMock->method('getUser')->willReturn([
+            'uid' => 1,
+            'username' => 'existingUserName'
+        ]);
+
+        $authServiceMock->method('authUser')->willReturn(true); // Auth services can return true or 200
+
+        // We need to wrap the array to something thats is \Traversable, in PHP 7.1 we can use traversable pseudo type instead
+        $this->subject->method('getAuthServices')->willReturn(new \ArrayIterator([$authServiceMock]));
+
+        // Should call regenerateSessionId
+        // New session should be stored with with old values
+        $this->subject->start();
+
+        $this->assertEquals('newSessionId', $this->subject->id);
+        $this->assertEquals($expectedUserId, $this->subject->user['uid']);
+        $this->subject->setSessionData('foobar', 'baz');
+        $this->assertArraySubset(['foo' => 'bar'], $this->subject->_get('sessionData'));
+        $this->assertTrue($this->subject->sesData_change);
+    }
+
+    /**
+     * removeSessionData should clear all session data
+     *
+     * @test
+     */
+    public function canRemoveSessionData()
+    {
+        // Mock SessionBackend
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+        $sessionBackend->method('get')->willReturn(
+            [
+                'ses_id' => 'existingId',
+                'ses_userid' => 1, // fe_user with uid 0 assumed in database, see fixtures.xml
+                'ses_data' => serialize(['foo' => 'bar']),
+                'ses_iplock' => 0,
+                'ses_tstamp' => time() + 100 // Return a time in future to make avoid mocking $GLOBALS['EXEC_TIME']
+            ]
+        );
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        $this->subject->method('getCookie')->willReturn('existingId');
+
+        $this->subject->start();
+
+        $this->subject->removeSessionData();
+        $this->assertEmpty($this->subject->getSessionData('foo'));
+        $this->subject->storeSessionData();
+        $this->assertEmpty($this->subject->getSessionData('foo'));
+    }
+
+    /**
+     * @test
+     *
+     * If a user has an anonymous session, and its data is set to null, then the record is removed
+     *
+     */
+    public function destroysAnonymousSessionIfDataIsNull()
+    {
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+        // Mock SessionBackend
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+
+        $this->subject->method('createSessionId')->willReturn('newSessionId');
+
+        $expectedSessionRecord = [
+            'ses_anonymous' => 1,
+            'ses_data' => serialize(['foo' => 'bar'])
+        ];
+
+        $sessionBackend->expects($this->at(0))->method('get')->willThrowException(new SessionNotFoundException('testing', 1486045419));
+        $sessionBackend->expects($this->at(1))->method('get')->willThrowException(new SessionNotFoundException('testing', 1486045420));
+        $sessionBackend->expects($this->at(2))->method('get')->willReturn([
+        'ses_id' => 'newSessionId',
+            'ses_anonymous' => 1
+        ]);
+
+        $sessionBackend->expects($this->once())
+            ->method('set')
+            ->with('newSessionId', new \PHPUnit_Framework_Constraint_ArraySubset($expectedSessionRecord))
+            ->willReturn([
+                'ses_id' => 'newSessionId',
+                'ses_anonymous' => 1,
+                'ses_data' => serialize(['foo' => 'bar'])
+            ]);
+
+        // Can set and store session data
+        $this->subject->start();
+        $this->assertEmpty($this->subject->_get('sessionData'));
+        $this->assertEmpty($this->subject->user);
+        $this->subject->setSessionData('foo', 'bar');
+        $this->assertAttributeNotEmpty('sessionData', $this->subject);
+        $this->subject->storeSessionData();
+
+        // Should delete session after setting to null
+        $this->subject->setSessionData('foo', null);
+        $this->assertAttributeEmpty('sessionData', $this->subject);
+        $sessionBackend->expects($this->once())->method('remove')->with('newSessionId');
+        $sessionBackend->expects($this->never())->method('update');
+
+        $this->subject->storeSessionData();
+    }
+
+    /**
+     * @test
+     * Any session data set when logged in should be preserved when logging out
+     *
+     */
+    public function sessionDataShouldBePreservedOnLogout()
+    {
+        $sessionBackend = $this->getMockBuilder(SessionBackendInterface::class)->getMock();
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        $this->subject->method('createSessionId')->willReturn('newSessionId');
+
+        $sessionBackend->method('get')->willReturn(
+            [
+                'ses_id' => 'existingId',
+                'ses_userid' => 1,
+                'ses_data' => serialize(['foo' => 'bar']),
+                'ses_iplock' => 0,
+                'ses_tstamp' => time() + 100 // Return a time in future to make avoid mocking $GLOBALS['EXEC_TIME']
+            ]
+        );
+        $this->subject->method('getSessionBackend')->willReturn($sessionBackend);
+        $this->subject->method('getCookie')->willReturn('existingId');
+
+        $this->subject->method('getRawUserByUid')->willReturn([
+            'uid' => 1,
+        ]);
+
+        // fix logout data
+        // Mock a logout attempt
+        $this->subject->method('getLoginFormData')->willReturn([
+            'status' => 'logout',
+
+        ]);
+
+        $sessionBackend->expects($this->once())->method('set')->with('newSessionId', $this->anything())->willReturnArgument(1);
+        $sessionBackend->expects($this->once())->method('remove')->with('existingId');
+
+        // start
+        $this->subject->start();
+        // asset that session data is there
+        $this->assertNotEmpty($this->subject->user);
+        $this->assertEquals(1, (int)$this->subject->user['ses_anonymous']);
+        $this->assertEquals(['foo' => 'bar'], $this->subject->_get('sessionData'));
+
+        $this->assertEquals('newSessionId', $this->subject->id);
+    }
+}
index 2a43061..c10865c 100644 (file)
@@ -44,17 +44,6 @@ CREATE TABLE fe_groups (
        KEY parent (pid)
 );
 
-#
-# Table structure for table 'fe_session_data'
-#
-CREATE TABLE fe_session_data (
-       hash varchar(32) DEFAULT '' NOT NULL,
-       content mediumblob,
-       tstamp int(11) unsigned DEFAULT '0' NOT NULL,
-
-       PRIMARY KEY (hash),
-       KEY tstamp (tstamp)
-) ENGINE=InnoDB;
 
 #
 # Table structure for table 'fe_sessions'
@@ -65,8 +54,9 @@ CREATE TABLE fe_sessions (
        ses_iplock varchar(39) DEFAULT '' NOT NULL,
        ses_userid int(11) unsigned DEFAULT '0' NOT NULL,
        ses_tstamp int(11) unsigned DEFAULT '0' NOT NULL,
-       ses_data blob,
+       ses_data mediumblob,
        ses_permanent tinyint(1) unsigned DEFAULT '0' NOT NULL,
+       ses_anonymous tinyint(1) unsigned DEFAULT '0' NOT NULL,
 
        PRIMARY KEY (ses_id,ses_name),
        KEY ses_tstamp (ses_tstamp)
index bfbcc99..594837a 100644 (file)
@@ -88,10 +88,6 @@ class CleanUp extends Action\AbstractAction
                 'description' => 'Frontend user sessions',
             ],
             [
-                'name' => 'fe_session_data',
-                'description' => 'Frontend user session data',
-            ],
-            [
                 'name' => 'sys_history',
                 'description' => 'Tracking of database record changes through TYPO3 backend forms',
             ],