diff --git a/composer.lock b/composer.lock index 682f8815e6402bf8f07c13605253e266f8bcc246..7bce767f046291be28ff83072fc8522c063d58df 100644 --- a/composer.lock +++ b/composer.lock @@ -8555,12 +8555,12 @@ "source": { "type": "git", "url": "https://github.com/TYPO3/testing-framework.git", - "reference": "ec6ce3a2731aa2dda89c42a4c9af9d1712457795" + "reference": "3038638bfb1135ac533069c44c2ecbc4047d8f18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TYPO3/testing-framework/zipball/ec6ce3a2731aa2dda89c42a4c9af9d1712457795", - "reference": "ec6ce3a2731aa2dda89c42a4c9af9d1712457795", + "url": "https://api.github.com/repos/TYPO3/testing-framework/zipball/3038638bfb1135ac533069c44c2ecbc4047d8f18", + "reference": "3038638bfb1135ac533069c44c2ecbc4047d8f18", "shasum": "" }, "require": { @@ -8622,7 +8622,7 @@ "issues": "https://github.com/TYPO3/testing-framework/issues", "source": "https://github.com/TYPO3/testing-framework/tree/main" }, - "time": "2022-08-12T17:18:51+00:00" + "time": "2022-10-03T18:49:08+00:00" } ], "aliases": [], @@ -8650,5 +8650,5 @@ "platform-overrides": { "php": "8.1.1" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.3.0" } diff --git a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php index d988351ac104c0e1cc12726787144d66d37a791c..9cbe1550efc70a23affe26029bb0c2362b222b74 100644 --- a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php +++ b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php @@ -323,9 +323,10 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface // SameSite "none" needs the secure option (only allowed on HTTPS) $isSecure = $cookieSameSite === Cookie::SAMESITE_NONE || GeneralUtility::getIndpEnv('TYPO3_SSL'); $sessionId = $this->userSession->getIdentifier(); + $cookieValue = $this->userSession->getJwt(); $this->setCookie = new Cookie( $this->name, - $sessionId, + $cookieValue, $cookieExpire, $cookiePath, $cookieDomain, diff --git a/typo3/sysext/core/Classes/Session/UserSession.php b/typo3/sysext/core/Classes/Session/UserSession.php index 7958c1c528189911d4f3bc7ff8c38561257fb36f..f86b9badd32942e3f921e413223e40191f8c32c5 100644 --- a/typo3/sysext/core/Classes/Session/UserSession.php +++ b/typo3/sysext/core/Classes/Session/UserSession.php @@ -17,6 +17,9 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Session; +use Firebase\JWT\JWT; +use TYPO3\CMS\Core\Security\JwtTrait; + /** * Represents all information about a user's session. * A user session can be bound to a frontend / backend user, or an anonymous session based on session data stored @@ -37,6 +40,8 @@ namespace TYPO3\CMS\Core\Session; */ class UserSession { + use JwtTrait; + protected const SESSION_UPDATE_GRACE_PERIOD = 61; protected string $identifier; protected ?int $userId; @@ -213,6 +218,24 @@ class UserSession return $GLOBALS['EXEC_TIME'] > ($this->lastUpdated + self::SESSION_UPDATE_GRACE_PERIOD); } + /** + * Gets session ID wrapped in JWT to be used for emitting a new cookie. + * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature>)>` + * + * @return string + */ + public function getJwt(): string + { + // @todo payload could be organized in a new `SessionToken` object + return self::encodeHashSignedJwt( + [ + 'identifier' => $this->identifier, + 'time' => (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339), + ], + self::createSigningKeyFromEncryptionKey(UserSession::class) + ); + } + /** * Create a new user session based on the provided session record * @@ -252,6 +275,24 @@ class UserSession return $userSession; } + /** + * Verifies and resolves session ID from submitted cookie value: + * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature>)>` + * + * @param string $cookieValue submitted cookie value + * @return non-empty-string|null session ID, null in case verification failed + * @throws \Exception + * @see getJwt() + */ + public static function resolveIdentifierFromJwt(string $cookieValue): ?string + { + if ($cookieValue === '') { + return null; + } + $payload = self::decodeJwt($cookieValue, self::createSigningKeyFromEncryptionKey(UserSession::class)); + return !empty($payload->identifier) && is_string($payload->identifier) ? $payload->identifier : null; + } + /** * Used internally to store data in the backend * diff --git a/typo3/sysext/core/Classes/Session/UserSessionManager.php b/typo3/sysext/core/Classes/Session/UserSessionManager.php index 4f347065c4d1b8f148e42081793f1a6b1bc4827f..369433d06f2d8579bbd3a6db9a1047714129a20d 100644 --- a/typo3/sysext/core/Classes/Session/UserSessionManager.php +++ b/typo3/sysext/core/Classes/Session/UserSessionManager.php @@ -85,8 +85,13 @@ class UserSessionManager implements LoggerAwareInterface */ public function createFromRequestOrAnonymous(ServerRequestInterface $request, string $cookieName): UserSession { - $sessionId = (string)($request->getCookieParams()[$cookieName] ?? ''); - return $this->getSessionFromSessionId($sessionId) ?? $this->createAnonymousSession(); + try { + $cookieValue = (string)($request->getCookieParams()[$cookieName] ?? ''); + $sessionId = UserSession::resolveIdentifierFromJwt($cookieValue); + } catch (\Exception $exception) { + $this->logger->debug('Could not resolve session identifier from JWT', ['exception' => $exception]); + } + return $this->getSessionFromSessionId($sessionId ?? '') ?? $this->createAnonymousSession(); } /** @@ -98,8 +103,13 @@ class UserSessionManager implements LoggerAwareInterface */ public function createFromGlobalCookieOrAnonymous(string $cookieName): UserSession { - $sessionId = isset($_COOKIE[$cookieName]) ? stripslashes((string)$_COOKIE[$cookieName]) : ''; - return $this->getSessionFromSessionId($sessionId) ?? $this->createAnonymousSession(); + try { + $cookieValue = isset($_COOKIE[$cookieName]) ? stripslashes((string)$_COOKIE[$cookieName]) : ''; + $sessionId = UserSession::resolveIdentifierFromJwt($cookieValue); + } catch (\Exception $exception) { + $this->logger->debug('Could not resolve session identifier from JWT', ['exception' => $exception]); + } + return $this->getSessionFromSessionId($sessionId ?? '') ?? $this->createAnonymousSession(); } /** diff --git a/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-94243-SendUserSessionCookiesAsHash-signedJWT.rst b/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-94243-SendUserSessionCookiesAsHash-signedJWT.rst new file mode 100644 index 0000000000000000000000000000000000000000..068bdc0b83e839bf451b4037c1d1b7a3e3bc990a --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-94243-SendUserSessionCookiesAsHash-signedJWT.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _breaking-94243-1664786038: + +=============================================================== +Breaking: #94243 - Send user session cookies as hash-signed JWT +=============================================================== + +See :issue:`94243` + +Description +=========== + +`JSON Web Tokens (JWT) <https://jwt.io/>`__ are used to transport user session +identifiers in `be_typo_user` and `fe_typo_user` cookies. Using JWT's `HS256` +(HMAC signed based on SHA256) allows to determine whether a session cookie is +valid before comparing with server-side stored session data. This enhances the +overall performance a bit, since sessions cookies would be checked for every +request to TYPO3's backend and frontend. + +JWT handling in PHP is provided by 3rd party package +`firebase/php-jwt <https://packagist.org/packages/firebase/php-jwt>`__. + + +Impact +====== + +Session cookies `be_typo_user` and `fe_typo_user` can be pre-validated without +querying the database, which can filter invalid requests and might reduce the +enhances the overall performance a bit. + +As a consequence session tokens are not sent "as is" anymore, but are +wrapped in a corresponding JWT message, which contains the following payload: + +* `identifier` reflects the actual session identifier +* `time` reflects the time of creating the cookie (RFC 3339 format) + + +Affected installations +====================== + +All instances using TYPO3 v12 and having custom implementations handling `be_typo_user` +and `fe_typo_user` cookie values. + + +Migration +========= + +Custom implementations handling `be_typo_user` or `fe_typo_user` cookies, +have to use the introduced method :php:`\TYPO3\CMS\Core\Session\UserSession::getJwt()` +instead of existing :php:`\TYPO3\CMS\Core\Session\UserSession::getIdentifier()`. + + +.. index:: Backend, Frontend, NotScanned, ext:core diff --git a/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php b/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php index a44b8de013407fbadb88be877b73521b93ce2c1b..e900f14636985d3c02b40162259c4b321d28b423 100644 --- a/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php +++ b/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php @@ -20,7 +20,9 @@ namespace TYPO3\CMS\Core\Tests\Unit\Session; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\NullLogger; use TYPO3\CMS\Core\Authentication\IpLocker; +use TYPO3\CMS\Core\Security\JwtTrait; use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException; use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface; use TYPO3\CMS\Core\Session\UserSession; @@ -30,6 +32,7 @@ use TYPO3\TestingFramework\Core\Unit\UnitTestCase; class UserSessionManagerTest extends UnitTestCase { use ProphecyTrait; + use JwtTrait; public function willExpireDataProvider(): array { @@ -88,6 +91,7 @@ class UserSessionManagerTest extends UnitTestCase */ public function createFromRequestOrAnonymousCreatesProperSessionObjects(): void { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = 'secret-encryption-key-test'; $sessionBackendProphecy = $this->prophesize(SessionBackendInterface::class); $sessionBackendProphecy->get('invalid-session')->willThrow(SessionNotFoundException::class); $sessionBackendProphecy->get('valid-session')->willReturn([ @@ -102,12 +106,20 @@ class UserSessionManagerTest extends UnitTestCase 50, new IpLocker(0, 0) ); + $subject->setLogger(new NullLogger()); $request = $this->prophesize(ServerRequestInterface::class); $request->getCookieParams()->willReturn([]); $anonymousSession = $subject->createFromRequestOrAnonymous($request->reveal(), 'foo'); self::assertTrue($anonymousSession->isNew()); self::assertTrue($anonymousSession->isAnonymous()); - $request->getCookieParams()->willReturn(['foo' => 'invalid-session', 'bar' => 'valid-session']); + $validSessionJwt = self::encodeHashSignedJwt( + [ + 'identifier' => 'valid-session', + 'time' => (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339), + ], + self::createSigningKeyFromEncryptionKey(UserSession::class) + ); + $request->getCookieParams()->willReturn(['foo' => 'invalid-session', 'bar' => $validSessionJwt]); $anonymousSessionFromInvalidBackendRequest = $subject->createFromRequestOrAnonymous($request->reveal(), 'foo'); self::assertTrue($anonymousSessionFromInvalidBackendRequest->isNew()); self::assertTrue($anonymousSessionFromInvalidBackendRequest->isAnonymous()); diff --git a/typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php b/typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php index 632c2c2271190f8dace7c36fb7ba759f207a2bd5..24f8f1a10ad9f99cbe4cffbee4d6f94e325c275a 100644 --- a/typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php +++ b/typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php @@ -17,11 +17,14 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Tests\Unit\Session; +use TYPO3\CMS\Core\Security\JwtTrait; use TYPO3\CMS\Core\Session\UserSession; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; class UserSessionTest extends UnitTestCase { + use JwtTrait; + /** * @test */ @@ -58,6 +61,7 @@ class UserSessionTest extends UnitTestCase self::assertTrue($session->dataWasUpdated()); self::assertEquals(['override' => 'data'], $session->getData()); + self::assertSame($record['ses_id'], UserSession::resolveIdentifierFromJwt($session->getJwt())); } /** diff --git a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php index 2226dc9bb1ba48061bd5e63ed2cf8ae3924ec5d3..2b9f6342dadf115fc3f039062e32cef67931567f 100644 --- a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php +++ b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php @@ -30,6 +30,7 @@ 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\Http\ServerRequest; +use TYPO3\CMS\Core\Security\JwtTrait; use TYPO3\CMS\Core\Security\RequestToken; use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface; use TYPO3\CMS\Core\Session\UserSession; @@ -47,6 +48,7 @@ use TYPO3\TestingFramework\Core\Unit\UnitTestCase; class FrontendUserAuthenticationTest extends UnitTestCase { use ProphecyTrait; + use JwtTrait; private const NOT_CHECKED_INDICATOR = '--not-checked--'; @@ -59,11 +61,19 @@ class FrontendUserAuthenticationTest extends UnitTestCase */ public function userFieldIsNotSetForAnonymousSessions(): void { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = 'secret-encryption-key-test'; $uniqueSessionId = StringUtility::getUniqueId('test'); + $uniqueSessionIdJwt = self::encodeHashSignedJwt( + [ + 'identifier' => $uniqueSessionId, + 'time' => (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339), + ], + self::createSigningKeyFromEncryptionKey(UserSession::class) + ); // Prepare a request with session id cookie $request = new ServerRequest('http://example.com/', 'GET', null, [], []); - $request = $request->withCookieParams(['fe_typo_user' => $uniqueSessionId]); + $request = $request->withCookieParams(['fe_typo_user' => $uniqueSessionIdJwt]); // Main session backend setup $sessionBackendProphecy = $this->prophesize(SessionBackendInterface::class); @@ -81,6 +91,7 @@ class FrontendUserAuthenticationTest extends UnitTestCase 86400, new IpLocker(0, 0) ); + $userSessionManager->setLogger(new NullLogger()); $subject = new FrontendUserAuthentication(); $subject->setLogger(new NullLogger()); $subject->initializeUserSessionManager($userSessionManager);