diff --git a/typo3/sysext/install/Classes/Controller/BackendModuleController.php b/typo3/sysext/install/Classes/Controller/BackendModuleController.php index 4d76a340c87ce507c4cde66818bdcf36f7da1e70..4476817d10ca174d878a3b999018463925413739 100644 --- a/typo3/sysext/install/Classes/Controller/BackendModuleController.php +++ b/typo3/sysext/install/Classes/Controller/BackendModuleController.php @@ -204,7 +204,7 @@ class BackendModuleController */ protected function setAuthorizedAndRedirect(string $controller): ResponseInterface { - $this->getSessionService()->setAuthorizedBackendSession(); + $this->getSessionService()->setAuthorizedBackendSession($this->getBackendUser()); $redirectLocation = 'install.php?install[controller]=' . $controller . '&install[context]=backend'; return new RedirectResponse($redirectLocation, 303); } diff --git a/typo3/sysext/install/Classes/Middleware/Maintenance.php b/typo3/sysext/install/Classes/Middleware/Maintenance.php index 335543a515980adcf3b87b84e3a14d35bec7a4bb..32ab1714b4519c08639058ea9bc49c388f6dbc30 100644 --- a/typo3/sysext/install/Classes/Middleware/Maintenance.php +++ b/typo3/sysext/install/Classes/Middleware/Maintenance.php @@ -145,6 +145,21 @@ class Maintenance implements MiddlewareInterface // session related actions $session = new SessionService(); + + // the backend user has an active session but the admin / maintainer + // rights have been revoked or the user was disabled or deleted in the meantime + if ($session->isAuthorizedBackendUserSession() && !$session->hasActiveBackendUserRoleAndSession()) { + // log out the user and destroy the session + $session->resetSession(); + $session->destroySession(); + $formProtection = FormProtectionFactory::get( + InstallToolFormProtection::class + ); + $formProtection->clean(); + + return new HtmlResponse('', 403); + } + if ($actionName === 'preAccessCheck') { $response = new JsonResponse([ 'installToolLocked' => !$this->checkEnableInstallToolFile(), diff --git a/typo3/sysext/install/Classes/Service/SessionService.php b/typo3/sysext/install/Classes/Service/SessionService.php index 2ec76f019021da009e795e7323e67214a6f3d88b..a85342c0209928a251178a718ae7314820e59b47 100644 --- a/typo3/sysext/install/Classes/Service/SessionService.php +++ b/typo3/sysext/install/Classes/Service/SessionService.php @@ -15,11 +15,19 @@ namespace TYPO3\CMS\Install\Service; +use Doctrine\DBAL\FetchMode; use Symfony\Component\HttpFoundation\Cookie; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Restriction\DefaultRestrictionContainer; +use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction; use TYPO3\CMS\Core\Http\CookieHeaderTrait; use TYPO3\CMS\Core\Messaging\FlashMessage; use TYPO3\CMS\Core\Security\BlockSerializationTrait; +use TYPO3\CMS\Core\Session\Backend\HashableSessionBackendInterface; +use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface; +use TYPO3\CMS\Core\Session\SessionManager; use TYPO3\CMS\Core\SingletonInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Install\Exception; @@ -202,14 +210,28 @@ class SessionService implements SingletonInterface /** * Marks this session as an "authorized by backend user" one. * This is called by BackendModuleController from backend context. + * + * @param BackendUserAuthentication $backendUser current backend user */ - public function setAuthorizedBackendSession() + public function setAuthorizedBackendSession(BackendUserAuthentication $backendUser) { + $nonce = bin2hex(random_bytes(20)); + $sessionBackend = $this->getBackendUserSessionBackend(); + // use hash mechanism of session backend, or pass plain value through generic hmac + $sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface + ? $sessionBackend->hash($backendUser->id) + : hash_hmac('sha256', $backendUser->id, $nonce); + $_SESSION['authorized'] = true; $_SESSION['lastSessionId'] = time(); $_SESSION['tstamp'] = time(); $_SESSION['expires'] = time() + $this->expireTimeInMinutes * 60; $_SESSION['isBackendSession'] = true; + $_SESSION['backendUserSession'] = [ + 'nonce' => $nonce, + 'userId' => (int)$backendUser->user['uid'], + 'hmac' => $sessionHmac, + ]; // Renew the session id to avoid session fixation $this->renewSession(); } @@ -236,7 +258,7 @@ class SessionService implements SingletonInterface * * @return bool TRUE if this session has been authorized before and initialized by a backend system maintainer */ - public function isAuthorizedBackendUserSession() + public function isAuthorizedBackendUserSession(): bool { if (!$this->hasSessionCookie()) { return false; @@ -248,6 +270,49 @@ class SessionService implements SingletonInterface return !$this->isExpired(); } + /** + * Evaluates whether the backend user that initiated this admin tool session, + * has an active role (is still admin & system maintainer) and has an active backend user interface session. + * + * @return bool whether the backend user has an active role and backend user interface session + */ + public function hasActiveBackendUserRoleAndSession(): bool + { + // @see \TYPO3\CMS\Install\Controller\BackendModuleController::setAuthorizedAndRedirect() + $backendUserSession = $this->getBackendUserSession(); + $backendUserRecord = $this->getBackendUserRecord($backendUserSession['userId']); + if ($backendUserRecord === null || empty($backendUserRecord['uid'])) { + return false; + } + $isAdmin = (($backendUserRecord['admin'] ?? 0) & 1) === 1; + $systemMaintainers = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []); + // stop here, in case the current admin tool session does not belong to a backend user having admin & maintainer privileges + if (!$isAdmin || !in_array((int)$backendUserRecord['uid'], $systemMaintainers, true)) { + return false; + } + + $sessionBackend = $this->getBackendUserSessionBackend(); + foreach ($sessionBackend->getAll() as $sessionRecord) { + $sessionUserId = (int)($sessionRecord['ses_userid'] ?? 0); + // skip, in case backend user id does not match + if ($backendUserSession['userId'] !== $sessionUserId) { + continue; + } + $sessionId = (string)($sessionRecord['ses_id'] ?? ''); + // use persisted hashed `ses_id` directly, or pass through hmac for plain values + $sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface + ? $sessionId + : hash_hmac('sha256', $sessionId, $backendUserSession['nonce']); + // skip, in case backend user session id does not match + if ($backendUserSession['hmac'] !== $sessionHmac) { + continue; + } + // backend user id and session id matched correctly + return true; + } + return false; + } + /** * Check if our session is expired. * Useful only right after a FALSE "isAuthorized" to see if this is the @@ -313,6 +378,20 @@ class SessionService implements SingletonInterface return $messages; } + /** + * @return array{userId: int, nonce: string, hmac: string} backend user session references + */ + public function getBackendUserSession(): array + { + if (empty($_SESSION['backendUserSession'])) { + throw new Exception( + 'The backend user session is only available if invoked via the backend user interface.', + 1624879295 + ); + } + return $_SESSION['backendUserSession']; + } + /** * Check if php session.auto_start is enabled * @@ -337,4 +416,52 @@ class SessionService implements SingletonInterface [FILTER_REQUIRE_SCALAR, FILTER_NULL_ON_FAILURE] ); } + + /** + * Fetching a user record with uid=$uid. + * Functionally similar to TYPO3\CMS\Core\Authentication\BackendUserAuthentication::setBeUserByUid(). + * + * @param int $uid The UID of the backend user + * @return array<string, int>|null The backend user record or NULL + */ + protected function getBackendUserRecord(int $uid): ?array + { + $restrictionContainer = GeneralUtility::makeInstance(DefaultRestrictionContainer::class); + $restrictionContainer->add(GeneralUtility::makeInstance(RootLevelRestriction::class, ['be_users'])); + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users'); + $queryBuilder->setRestrictions($restrictionContainer); + $queryBuilder->select('uid', 'admin') + ->from('be_users') + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT))); + + $resetBeUsersTca = false; + if (!isset($GLOBALS['TCA']['be_users'])) { + // The admin tool intentionally does not load any TCA information at this time. + // The database restictions, needs the enablecolumns TCA information + // for 'be_users' to load the user correctly. + // That is why this part of the TCA ($GLOBALS['TCA']['be_users']['ctrl']['enablecolumns']) + // is simulated. + // The simulation state will be removed later to avoid unexpected side effects. + $GLOBALS['TCA']['be_users']['ctrl']['enablecolumns'] = [ + 'rootLevel' => 1, + 'deleted' => 'deleted', + 'disabled' => 'disable', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ]; + $resetBeUsersTca = true; + } + $result = $queryBuilder->execute()->fetch(FetchMode::ASSOCIATIVE); + if ($resetBeUsersTca) { + unset($GLOBALS['TCA']['be_users']); + } + + return is_array($result) ? $result : null; + } + + protected function getBackendUserSessionBackend(): SessionBackendInterface + { + return GeneralUtility::makeInstance(SessionManager::class)->getSessionBackend('BE'); + } } diff --git a/typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php b/typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php index c2cbe2a667d31e93ee49b85176845647fe4ffcba..fbe9fd063b5057a0585749a646d3e4bbfb83f411 100644 --- a/typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php +++ b/typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php @@ -17,6 +17,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Install\Tests\Functional\Controller; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\Core\ApplicationContext; use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Install\Controller\BackendModuleController; @@ -56,7 +57,13 @@ class BackendModuleControllerTest extends FunctionalTestCase Environment::isWindows() ? 'WINDOWS' : 'UNIX' ); - // Authorized redirect to the install tool is performed, sudo mode is not required + // Authorized redirect to the admin tool is performed + // sudo mode is not required (due to development context) + $GLOBALS['BE_USER'] = new BackendUserAuthentication(); + // using anonymous user session, which is fine for this test case + $GLOBALS['BE_USER']->id = $GLOBALS['BE_USER']->createSessionId(); + $GLOBALS['BE_USER']->user = ['uid' => 1]; + $response = $subject->{$action}(); self::assertEquals(303, $response->getStatusCode()); self::assertNotEmpty($response->getHeader('location'));