diff --git a/composer.json b/composer.json index 13d2e063c2bb574ac7206b8e10b364e7b3d58821..8edc7d47fd9fc714d6d11624bde2b3f7d8ed2f8a 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,7 @@ "doctrine/lexer": "^1.2.3", "egulias/email-validator": "^3.2.1", "enshrined/svg-sanitize": "^0.15.4", + "firebase/php-jwt": "^6.3", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/promises": "^1.4.0", "guzzlehttp/psr7": "^1.8.5 || ^2.1.2", diff --git a/composer.lock b/composer.lock index 2952a3f6dd023db42e98d0dbe5f86f0851168218..95c408262883758f17243a6528d0e3cfcc8af2dc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "51ec4a5a4db76370664064eb7ef9751c", + "content-hash": "e2f2f173c9b9b8c7168c220e20812786", "packages": [ { "name": "bacon/bacon-qr-code", @@ -766,6 +766,68 @@ }, "time": "2022-02-21T09:13:59+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.3.0", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "018dfc4e1da92ad8a1b90adc4893f476a3b41cb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/018dfc4e1da92ad8a1b90adc4893f476a3b41cb8", + "reference": "018dfc4e1da92ad8a1b90adc4893f476a3b41cb8", + "shasum": "" + }, + "require": { + "php": "^7.1||^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.5||^7.4", + "phpspec/prophecy-phpunit": "^1.1", + "phpunit/phpunit": "^7.5||^9.5", + "psr/cache": "^1.0||^2.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.3.0" + }, + "time": "2022-07-15T16:48:45+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.4.5", @@ -8344,12 +8406,12 @@ "source": { "type": "git", "url": "https://github.com/TYPO3/styleguide.git", - "reference": "cae579350467f1d60bd0418f59e5459f1559b598" + "reference": "5759503933c13dd5ea13c535ac0c026b3a2a0281" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TYPO3/styleguide/zipball/cae579350467f1d60bd0418f59e5459f1559b598", - "reference": "cae579350467f1d60bd0418f59e5459f1559b598", + "url": "https://api.github.com/repos/TYPO3/styleguide/zipball/5759503933c13dd5ea13c535ac0c026b3a2a0281", + "reference": "5759503933c13dd5ea13c535ac0c026b3a2a0281", "shasum": "" }, "require-dev": { @@ -8408,7 +8470,7 @@ "issues": "https://github.com/TYPO3/styleguide/issues", "source": "https://github.com/TYPO3/styleguide/tree/main" }, - "time": "2022-09-13T17:10:27+00:00" + "time": "2022-09-26T09:45:10+00:00" }, { "name": "typo3/testing-framework", diff --git a/typo3/sysext/backend/Classes/Controller/LoginController.php b/typo3/sysext/backend/Classes/Controller/LoginController.php index b4a0c0b2976fe4bb4bde2238e8d43d2edd0b8784..ebb30f1ca5051514c09e96ed2f278a2cee8f408c 100644 --- a/typo3/sysext/backend/Classes/Controller/LoginController.php +++ b/typo3/sysext/backend/Classes/Controller/LoginController.php @@ -33,6 +33,7 @@ use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\SecurityAspect; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\FormProtection\BackendFormProtection; use TYPO3\CMS\Core\FormProtection\FormProtectionFactory; @@ -44,6 +45,7 @@ use TYPO3\CMS\Core\Localization\LanguageService; use TYPO3\CMS\Core\Localization\Locales; use TYPO3\CMS\Core\Page\PageRenderer; use TYPO3\CMS\Core\Routing\BackendEntryPointResolver; +use TYPO3\CMS\Core\Security\RequestToken; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Fluid\View\StandaloneView; @@ -295,6 +297,8 @@ class LoginController 'hasLoginError' => $this->isLoginInProgress($request), 'action' => $action, 'formActionUrl' => $formActionUrl, + 'requestTokenName' => RequestToken::PARAM_NAME, + 'requestTokenValue' => $this->provideRequestTokenJwt(), 'forgetPasswordUrl' => $this->uriBuilder->buildUriWithRedirect( 'password_forget', ['loginProvider' => $this->loginProviderIdentifier], @@ -425,6 +429,12 @@ class LoginController throw new PropagateResponseException(new RedirectResponse($this->redirectToURL, 303), 1607271511); } + protected function provideRequestTokenJwt(): string + { + $nonce = SecurityAspect::provideIn($this->context)->provideNonce(); + return RequestToken::create('core/user-auth/be')->toHashSignedJwt($nonce); + } + protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; diff --git a/typo3/sysext/backend/Configuration/RequestMiddlewares.php b/typo3/sysext/backend/Configuration/RequestMiddlewares.php index 4e11b55e9b3b4f1bb6f9d5351d21efd0645a533e..46cf14492917937058bbd42ebb80a69f34387c44 100644 --- a/typo3/sysext/backend/Configuration/RequestMiddlewares.php +++ b/typo3/sysext/backend/Configuration/RequestMiddlewares.php @@ -41,6 +41,15 @@ return [ 'typo3/cms-backend/https-redirector', ], ], + 'typo3/cms-core/request-token-middleware' => [ + 'target' => \TYPO3\CMS\Core\Middleware\RequestTokenMiddleware::class, + 'after' => [ + 'typo3/cms-backend/backend-routing', + ], + 'before' => [ + 'typo3/cms-backend/authentication', + ], + ], 'typo3/cms-backend/authentication' => [ 'target' => \TYPO3\CMS\Backend\Middleware\BackendUserAuthenticator::class, 'after' => [ diff --git a/typo3/sysext/backend/Resources/Private/Layouts/Login.html b/typo3/sysext/backend/Resources/Private/Layouts/Login.html index 0dc6ffc6dac02b08dfe3299b98615bdfac9bde27..7934d9d4d0a7216661dbaa6bf8b2c23c0013612f 100644 --- a/typo3/sysext/backend/Resources/Private/Layouts/Login.html +++ b/typo3/sysext/backend/Resources/Private/Layouts/Login.html @@ -48,6 +48,7 @@ <input type="hidden" name="userident" id="t3-field-userident" class="t3js-login-userident-field" value="" /> <input type="hidden" name="redirect_url" value="{redirectUrl}" /> <input type="hidden" name="loginRefresh" value="{loginRefresh}" /> + <input type="hidden" name="{requestTokenName}" value="{requestTokenValue}" /> <f:render section="loginFormFields" /> diff --git a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php index 70bb33388783c005c3c07143a8be0119b37df435..d988351ac104c0e1cc12726787144d66d37a791c 100644 --- a/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php +++ b/typo3/sysext/core/Classes/Authentication/AbstractUserAuthentication.php @@ -22,6 +22,8 @@ use Psr\Log\LoggerAwareTrait; use Symfony\Component\HttpFoundation\Cookie; use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry; use TYPO3\CMS\Core\Authentication\Mfa\MfaRequiredException; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\SecurityAspect; use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; @@ -34,6 +36,7 @@ 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\Http\CookieHeaderTrait; +use TYPO3\CMS\Core\Security\RequestToken; use TYPO3\CMS\Core\Session\UserSession; use TYPO3\CMS\Core\Session\UserSessionManager; use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction; @@ -494,6 +497,22 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface $this->logger->debug('No user session found'); } + if ($activeLogin) { + $context = GeneralUtility::makeInstance(Context::class); + $securityAspect = SecurityAspect::provideIn($context); + $requestToken = $securityAspect->getReceivedRequestToken(); + $requestTokenScopeMatches = ($requestToken->scope ?? null) === 'core/user-auth/' . strtolower($this->loginType); + if (!$requestTokenScopeMatches) { + $this->logger->debug('Missing or invalid request token during login', ['requestToken' => $requestToken]); + // important: disable `$activeLogin` state + $activeLogin = false; + } elseif ($requestToken instanceof RequestToken && $requestToken->getSigningSecretIdentifier() !== null) { + $securityAspect->getSigningSecretResolver()->revokeIdentifier( + $requestToken->getSigningSecretIdentifier() + ); + } + } + // Fetch users from the database (or somewhere else) $possibleUsers = $this->fetchPossibleUsers($loginData, $activeLogin, $isExistingSession, $authenticatedUserFromSession); diff --git a/typo3/sysext/core/Classes/Context/SecurityAspect.php b/typo3/sysext/core/Classes/Context/SecurityAspect.php new file mode 100644 index 0000000000000000000000000000000000000000..aa10a59c93394fc7b7c4f2de9d48b80e3d1c299c --- /dev/null +++ b/typo3/sysext/core/Classes/Context/SecurityAspect.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Context; + +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\CMS\Core\Security\NoncePool; +use TYPO3\CMS\Core\Security\RequestToken; +use TYPO3\CMS\Core\Security\SigningSecretResolver; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +class SecurityAspect implements AspectInterface +{ + /** + * `null` in case no request taken was received + * `false` in case a request token was received, which was invalid + */ + protected RequestToken|false|null $receivedRequestToken = null; + + protected SigningSecretResolver $signingSecretResolver; + + protected NoncePool $noncePool; + + public static function provideIn(Context $context): self + { + if ($context->hasAspect('security')) { + $securityAspect = $context->getAspect('security'); + } + if (!isset($securityAspect) || !$securityAspect instanceof SecurityAspect) { + $securityAspect = GeneralUtility::makeInstance(SecurityAspect::class); + $context->setAspect('security', $securityAspect); + } + return $securityAspect; + } + + public function __construct() + { + $this->noncePool = GeneralUtility::makeInstance(NoncePool::class); + $this->signingSecretResolver = GeneralUtility::makeInstance( + SigningSecretResolver::class, + [ + 'nonce' => $this->noncePool, + // @todo enrich in separate step with `*FormProtection` + ] + ); + } + + public function get(string $name): null|bool|Nonce|RequestToken + { + return match ($name) { + 'receivedRequestToken' => $this->receivedRequestToken, + 'signingSecretResolver' => $this->signingSecretResolver, + 'noncePool' => $this->noncePool, + default => null, + }; + } + + public function getReceivedRequestToken(): RequestToken|false|null + { + return $this->receivedRequestToken; + } + + public function setReceivedRequestToken(RequestToken|false|null $receivedRequestToken): void + { + $this->receivedRequestToken = $receivedRequestToken; + } + + /** + * Resolves corresponding signing secret providers (such as `NoncePool`). + * Example: `...->getSigningSecretResolver->findByType('nonce')` resolves `NoncePool` + */ + public function getSigningSecretResolver(): SigningSecretResolver + { + return $this->signingSecretResolver; + } + + public function getNoncePool(): NoncePool + { + return $this->noncePool; + } + + /** + * Shortcut function to `NoncePool`, providing a `SigningSecret` + * @todo this is a "comfort function", might be dropped + */ + public function provideNonce(): Nonce + { + return $this->noncePool->provideSigningSecret(); + } +} diff --git a/typo3/sysext/core/Classes/Middleware/RequestTokenMiddleware.php b/typo3/sysext/core/Classes/Middleware/RequestTokenMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..49d9546c6497327ee5d7b9db3695e106bb084a2a --- /dev/null +++ b/typo3/sysext/core/Classes/Middleware/RequestTokenMiddleware.php @@ -0,0 +1,163 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Middleware; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\HttpFoundation\Cookie; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\SecurityAspect; +use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\CMS\Core\Security\NonceException; +use TYPO3\CMS\Core\Security\NoncePool; +use TYPO3\CMS\Core\Security\RequestToken; +use TYPO3\CMS\Core\Security\RequestTokenException; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +class RequestTokenMiddleware implements MiddlewareInterface, LoggerAwareInterface +{ + use LoggerAwareTrait; + + protected const COOKIE_PREFIX = 'typo3nonce_'; + protected const SECURE_PREFIX = '__Secure-'; + + protected const ALLOWED_METHODS = ['POST', 'PUT', 'PATCH']; + + protected SecurityAspect $securityAspect; + protected NoncePool $noncePool; + + public function __construct(Context $context) + { + $this->securityAspect = SecurityAspect::provideIn($context); + $this->noncePool = $this->securityAspect->getNoncePool(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // @todo someâ„¢ route handling mechanism might verify request-tokens (-> e.g. backend-routes, unsure for frontend) + $this->noncePool->merge($this->resolveNoncePool($request))->purge(); + + try { + $this->securityAspect->setReceivedRequestToken($this->resolveReceivedRequestToken($request)); + } catch (RequestTokenException $exception) { + // request token was given, but could not be verified + $this->securityAspect->setReceivedRequestToken(false); + $this->logger->debug('Could not resolve request token', ['exception' => $exception]); + } + + $response = $handler->handle($request); + return $this->enrichResponseWithCookie($request, $response); + } + + protected function resolveNoncePool(ServerRequestInterface $request): NoncePool + { + $secure = $this->isHttps($request); + // resolves cookie name dependent on whether TLS is used in request and uses `__Secure-` prefix, + // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes + $securePrefix = $secure ? self::SECURE_PREFIX : ''; + $cookiePrefix = $securePrefix . self::COOKIE_PREFIX; + $cookiePrefixLength = strlen($cookiePrefix); + $cookies = array_filter( + $request->getCookieParams(), + static fn ($name) => str_starts_with($name, $cookiePrefix), + ARRAY_FILTER_USE_KEY + ); + $items = []; + foreach ($cookies as $name => $value) { + $name = substr($name, $cookiePrefixLength); + try { + $items[$name] = Nonce::fromHashSignedJwt($value); + } catch (NonceException $exception) { + $this->logger->debug('Could not resolve received nonce', ['exception' => $exception]); + $items[$name] = null; + } + } + // @todo pool `$options` should be configurable via `$TYPO3_CONF_VARS` + return GeneralUtility::makeInstance(NoncePool::class, $items); + } + + /** + * @throws RequestTokenException + */ + protected function resolveReceivedRequestToken(ServerRequestInterface $request): ?RequestToken + { + $headerValue = $request->getHeaderLine(RequestToken::HEADER_NAME); + $paramValue = (string)($request->getParsedBody()[RequestToken::PARAM_NAME] ?? ''); + if ($headerValue !== '') { + $tokenValue = $headerValue; + } elseif (in_array($request->getMethod(), self::ALLOWED_METHODS, true)) { + $tokenValue = $paramValue; + } else { + $tokenValue = ''; + } + if ($tokenValue === '') { + return null; + } + return RequestToken::fromHashSignedJwt($tokenValue, $this->securityAspect->getSigningSecretResolver()); + } + + protected function enrichResponseWithCookie(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $secure = $this->isHttps($request); + $normalizedParams = $request->getAttribute('normalizedParams'); + $path = $normalizedParams->getSitePath(); + $securePrefix = $secure ? self::SECURE_PREFIX : ''; + $cookiePrefix = $securePrefix . self::COOKIE_PREFIX; + + $createCookie = static fn (string $name, string $value, int $expire): Cookie => new Cookie( + $name, + $value, + $expire, + $path, + null, + $secure, + true, + false, + Cookie::SAMESITE_STRICT + ); + + $cookies = []; + // emit new nonce cookies + foreach ($this->noncePool->getEmittableNonces() as $name => $nonce) { + $cookies[] = $createCookie($cookiePrefix . $name, $nonce->toHashSignedJwt(), 0); + } + // revoke nonce cookies (exceeded pool size, expired or explicitly revoked) + foreach ($this->noncePool->getRevocableNames() as $name) { + $cookies[] = $createCookie($cookiePrefix . $name, '', -1); + } + // finally apply to response + foreach ($cookies as $cookie) { + $response = $response->withAddedHeader('Set-Cookie', (string)$cookie); + } + return $response; + } + + protected function isHttps(ServerRequestInterface $request): bool + { + $normalizedParams = $request->getAttribute('normalizedParams'); + return $normalizedParams instanceof NormalizedParams && $normalizedParams->isHttps(); + } +} diff --git a/typo3/sysext/core/Classes/Security/JwtTrait.php b/typo3/sysext/core/Classes/Security/JwtTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..f7dade703f6bb38a1c3b8a8bd28fd144b10718be --- /dev/null +++ b/typo3/sysext/core/Classes/Security/JwtTrait.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +/** + * Trait providing support for JWT using symmetric hash signing. + * + * The benefit of using a trait in this particular case is, that defaults in `self::class` + * (used as a pepper during the singing process) are specific for that a particular implementation. + * + * @internal + */ +trait JwtTrait +{ + private static function getDefaultSigningAlgorithm(): string + { + return 'HS256'; + } + + private static function createSigningKeyFromEncryptionKey(string $pepper = self::class): Key + { + if ($pepper === '') { + $pepper = self::class; + } + $encryptionKey = $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? ''; + $keyMaterial = hash('sha256', $encryptionKey) . '/' . $pepper; + return new Key($keyMaterial, self::getDefaultSigningAlgorithm()); + } + + private static function createSigningSecret(SigningSecretInterface $secret, string $pepper = self::class): Key + { + if ($pepper === '') { + $pepper = self::class; + } + $keyMaterial = $secret->getSigningSecret() . '/' . $pepper; + return new Key($keyMaterial, self::getDefaultSigningAlgorithm()); + } + + private static function encodeHashSignedJwt(array $payload, Key $key, SecretIdentifier $identifier = null): string + { + // @todo work-around until https://github.com/firebase/php-jwt/pull/446/files is merged + $errorLevel = self::ignoreJwtPhp82Deprecations(); + + $keyId = $identifier !== null ? json_encode($identifier) : null; + $jwt = JWT::encode($payload, $key->getKeyMaterial(), self::getDefaultSigningAlgorithm(), $keyId); + + if (is_int($errorLevel)) { + error_reporting($errorLevel); + } + + return $jwt; + } + + private static function decodeJwt(string $jwt, Key $key, bool $associative = false): \stdClass|array + { + // @todo work-around until https://github.com/firebase/php-jwt/pull/446/files is merged + $errorLevel = self::ignoreJwtPhp82Deprecations(); + + $payload = JWT::decode($jwt, $key); + + if (is_int($errorLevel)) { + error_reporting($errorLevel); + } + + return $associative ? json_decode(json_encode($payload), true) : $payload; + } + + private static function decodeJwtHeader(string $jwt, string $property): mixed + { + $parts = explode('.', $jwt); + if (count($parts) !== 3) { + return null; + } + $headerRaw = JWT::urlsafeB64Decode($parts[0]); + if (($header = JWT::jsonDecode($headerRaw)) === null) { + return null; + } + return $header->{$property} ?? null; + } + + private static function ignoreJwtPhp82Deprecations(): ?int + { + $php82 = version_compare(PHP_VERSION, '8.1.999', '>'); + if (!$php82) { + return null; + } + $errorLevel = error_reporting(); + return error_reporting($errorLevel ^ E_DEPRECATED); + } +} diff --git a/typo3/sysext/core/Classes/Security/Nonce.php b/typo3/sysext/core/Classes/Security/Nonce.php new file mode 100644 index 0000000000000000000000000000000000000000..4a2ce06d9a80ae38c7de5bc3f9bd74a27fb9ad54 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/Nonce.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\StringUtility; + +/** + * Number used once... + * + * @internal + */ +class Nonce implements SigningSecretInterface +{ + use JwtTrait; + + protected const MIN_BYTES = 40; + + public readonly string $b64; + public readonly \DateTimeImmutable $time; + + public static function create(int $length = self::MIN_BYTES): self + { + return GeneralUtility::makeInstance(self::class, random_bytes(max(self::MIN_BYTES, $length))); + } + + public static function fromHashSignedJwt(string $jwt): self + { + try { + $payload = self::decodeJwt($jwt, self::createSigningKeyFromEncryptionKey(), true); + return GeneralUtility::makeInstance( + self::class, + StringUtility::base64urlDecode($payload['nonce'] ?? ''), + \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $payload['time'] ?? null) + ); + } catch (\Throwable $t) { + throw new NonceException('Could not reconstitute nonce', 1651771351, $t); + } + } + + public function __construct(public readonly string $binary, \DateTimeImmutable $time = null) + { + if (strlen($this->binary) < self::MIN_BYTES) { + throw new \LogicException( + sprintf('Value must have at least %d bytes', self::MIN_BYTES), + 1651785134 + ); + } + $this->b64 = StringUtility::base64urlEncode($this->binary); + // drop microtime, second is the minimum date-interval + $this->time = \DateTimeImmutable::createFromFormat( + \DateTimeImmutable::RFC3339, + ($time ?? new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339) + ); + } + + public function getSigningIdentifier(): SecretIdentifier + { + return new SecretIdentifier('nonce', StringUtility::base64urlEncode(md5($this->binary, true))); + } + + public function getSigningSecret(): string + { + return hash('sha256', $this->binary); + } + + public function toHashSignedJwt(): string + { + $payload = [ + 'nonce' => $this->b64, + 'time' => $this->time->format(\DateTimeImmutable::RFC3339), + ]; + return self::encodeHashSignedJwt($payload, self::createSigningKeyFromEncryptionKey()); + } +} diff --git a/typo3/sysext/core/Classes/Security/NonceException.php b/typo3/sysext/core/Classes/Security/NonceException.php new file mode 100644 index 0000000000000000000000000000000000000000..46e5908a2b83bcb7ea91066d3f226fbaf192e028 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/NonceException.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Exception; + +/** + * @internal + */ +class NonceException extends Exception +{ +} diff --git a/typo3/sysext/core/Classes/Security/NoncePool.php b/typo3/sysext/core/Classes/Security/NoncePool.php new file mode 100644 index 0000000000000000000000000000000000000000..2566737c89ac4b76aae26ae6d26b7f41ca1005dc --- /dev/null +++ b/typo3/sysext/core/Classes/Security/NoncePool.php @@ -0,0 +1,168 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * @internal + */ +class NoncePool implements SigningProviderInterface +{ + /** + * maximum amount of items in pool + */ + protected const DEFAULT_SIZE = 5; + + /** + * items will expire after this amount of seconds + */ + protected const DEFAULT_EXPIRATION = 900; + + /** + * @var array{size: positive-int, expiration: int} + */ + protected array $options; + + /** + * @var array<string, Nonce> + */ + protected array $items; + + /** + * @var array<string, ?Nonce> + */ + protected array $changeItems = []; + + /** + * @param array $nonces + * @param array<string, mixed> $options + */ + public function __construct(array $nonces = [], array $options = []) + { + $this->options = [ + 'size' => max(1, (int)($options['size'] ?? self::DEFAULT_SIZE)), + 'expiration' => max(0, (int)($options['expiration'] ?? self::DEFAULT_EXPIRATION)), + ]; + + foreach ($nonces as $name => $value) { + if ($value !== null && !$value instanceof Nonce) { + throw new \LogicException(sprintf('Invalid valid for nonce "%s"', $name), 1664195013); + } + } + // filter valid items + $this->items = array_filter( + $nonces, + fn (?Nonce $item, string $name) => $item !== null + && $this->isValidNonceName($item, $name) + && $this->isNonceUpToDate($item), + ARRAY_FILTER_USE_BOTH + ); + // items that were not valid -> to be revoked + $invalidItems = array_diff_key($nonces, $this->items); + $this->changeItems = array_fill_keys(array_keys($invalidItems), null); + } + + public function findSigningSecret(string $name): ?Nonce + { + return $this->items[$name] ?? null; + } + + public function provideSigningSecret(): Nonce + { + $items = array_filter($this->changeItems); + $nonce = reset($items); + if (!$nonce instanceof Nonce) { + $nonce = Nonce::create(); + $this->emit($nonce); + } + return $nonce; + } + + public function merge(self $other): self + { + $this->items = array_merge($this->items, $other->items); + $this->changeItems = array_merge($this->changeItems, $other->changeItems); + return $this; + } + + public function purge(): self + { + $size = $this->options['size']; + $items = array_filter($this->items); + if (count($items) <= $size) { + return $this; + } + uasort($items, static fn (Nonce $a, Nonce $b) => $b->time <=> $a->time); + $exceedingItems = array_splice($items, $size, null, []); + foreach ($exceedingItems as $name => $_) { + $this->changeItems[$name] = null; + } + return $this; + } + + public function emit(Nonce $nonce): self + { + $this->changeItems[$nonce->getSigningIdentifier()->name] = $nonce; + return $this; + } + + public function revoke(Nonce $nonce): self + { + $this->revokeSigningSecret($nonce->getSigningIdentifier()->name); + return $this; + } + + public function revokeSigningSecret(string $name): void + { + if (isset($this->items[$name])) { + $this->changeItems[$name] = null; + } + } + + /** + * @return array<string, Nonce> + */ + public function getEmittableNonces(): array + { + return array_filter($this->changeItems); + } + + /** + * @return list<string> + */ + public function getRevocableNames(): array + { + return array_keys( + array_diff_key($this->changeItems, $this->getEmittableNonces()) + ); + } + + protected function isValidNonceName(Nonce $nonce, $name): bool + { + return $nonce->getSigningIdentifier()->name === $name; + } + + protected function isNonceUpToDate(Nonce $nonce): bool + { + if ($this->options['expiration'] <= 0) { + return true; + } + $now = new \DateTimeImmutable(); + $interval = new \DateInterval(sprintf('PT%dS', $this->options['expiration'])); + return $nonce->time->add($interval) > $now; + } +} diff --git a/typo3/sysext/core/Classes/Security/RequestToken.php b/typo3/sysext/core/Classes/Security/RequestToken.php new file mode 100644 index 0000000000000000000000000000000000000000..fc0fc69fbc733acf11662dfccccabfaf637cb5db --- /dev/null +++ b/typo3/sysext/core/Classes/Security/RequestToken.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +class RequestToken +{ + use JwtTrait; + + public const PARAM_NAME = '__RequestToken'; + public const HEADER_NAME = 'X-TYPO3-RequestToken'; + + public readonly string $scope; + public readonly \DateTimeImmutable $time; + /** + * @var array<int|string, mixed> + */ + public readonly array $params; + + /** + * Identifier that was used for signing, filled when decoding. + */ + private ?SecretIdentifier $signingSecretIdentifier = null; + + public static function create(string $scope): self + { + return GeneralUtility::makeInstance(self::class, $scope); + } + + public static function fromHashSignedJwt(string $jwt, SigningSecretInterface|SigningSecretResolver $secret): self + { + // invokes resolver to retrieve corresponding secret + // a hint was stored in the `kid` (keyId) property of the JWT header + if ($secret instanceof SigningSecretResolver) { + $kid = (string)self::decodeJwtHeader($jwt, 'kid'); + try { + $identifier = SecretIdentifier::fromJson($kid); + $secret = $secret->findByIdentifier($identifier); + } catch (\Throwable $t) { + throw new RequestTokenException('Could not reconstitute request token', 1664202134, $t); + } + if ($secret === null) { + throw new RequestTokenException('Could not reconstitute request token', 1664202135); + } + } + + try { + $payload = self::decodeJwt($jwt, self::createSigningSecret($secret), true); + $subject = GeneralUtility::makeInstance( + self::class, + $payload['scope'] ?? '', + \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $payload['time'] ?? null), + $payload['params'] ?? [] + ); + $subject->signingSecretIdentifier = $secret->getSigningIdentifier(); + return $subject; + } catch (\Throwable $t) { + throw new RequestTokenException('Could not reconstitute request token', 1651771352, $t); + } + } + + public function __construct(string $scope, \DateTimeImmutable $time = null, array $params = []) + { + $this->scope = $scope; + // drop microtime, second is the minimum date-interval + $this->time = \DateTimeImmutable::createFromFormat( + \DateTimeImmutable::RFC3339, + ($time ?? new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339) + ); + $this->params = $params; + } + + public function toHashSignedJwt(SigningSecretInterface $secret): string + { + $payload = [ + 'scope' => $this->scope, + 'time' => $this->time->format(\DateTimeImmutable::RFC3339), + 'params' => $this->params, + ]; + return self::encodeHashSignedJwt( + $payload, + self::createSigningSecret($secret), + $secret->getSigningIdentifier() + ); + } + + public function withParams(array $params): self + { + return GeneralUtility::makeInstance(self::class, $this->scope, $this->time, $params); + } + + public function withMergedParams(array $params): self + { + return $this->withParams(array_merge_recursive($this->params, $params)); + } + + public function getSigningSecretIdentifier(): ?SecretIdentifier + { + return $this->signingSecretIdentifier; + } +} diff --git a/typo3/sysext/core/Classes/Security/RequestTokenException.php b/typo3/sysext/core/Classes/Security/RequestTokenException.php new file mode 100644 index 0000000000000000000000000000000000000000..868b4642a3e916baa02b876020f35f7d63c8a126 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/RequestTokenException.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Exception; + +/** + * @internal + */ +class RequestTokenException extends Exception +{ +} diff --git a/typo3/sysext/core/Classes/Security/SecretIdentifier.php b/typo3/sysext/core/Classes/Security/SecretIdentifier.php new file mode 100644 index 0000000000000000000000000000000000000000..830d7f688d83166319354a8469434306f7de7761 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/SecretIdentifier.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Model used to identify a secret, without actually containing the secret value. + * + * @internal + */ +class SecretIdentifier implements \JsonSerializable +{ + public static function fromJson(string $json): self + { + return self::fromArray( + (array)json_decode($json, true, 8, JSON_THROW_ON_ERROR) + ); + } + + public static function fromArray(array $payload): self + { + $type = $payload['type'] ?? null; + $name = $payload['name'] ?? null; + if (!is_string($type) || !is_string($name)) { + throw new \LogicException('Properties "type" and "name" must be of type string', 1664215980); + } + return GeneralUtility::makeInstance(self::class, $type, $name); + } + + public function __construct(public readonly string $type, public readonly string $name) + { + } + + /** + * @return array{type: string, name: string} + */ + public function jsonSerialize(): array + { + return [ + 'type' => $this->type, + 'name' => $this->name, + ]; + } +} diff --git a/typo3/sysext/core/Classes/Security/SigningProviderInterface.php b/typo3/sysext/core/Classes/Security/SigningProviderInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..ee373186b038dbf20737e8b78d9e039fd20e0b9e --- /dev/null +++ b/typo3/sysext/core/Classes/Security/SigningProviderInterface.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * @internal + */ +interface SigningProviderInterface +{ + /** + * Provides a signing secret independently of any name or identifier. + * In case there is none, the corresponding provider has to create a new one. + */ + public function provideSigningSecret(): SigningSecretInterface; + + /** + * Finds a signing secret for a given name + */ + public function findSigningSecret(string $name): ?SigningSecretInterface; + + /** + * Revokes a signing secret for a given name + * (providers without revocation functionality use an empty method body) + */ + public function revokeSigningSecret(string $name): void; +} diff --git a/typo3/sysext/core/Classes/Security/SigningSecretInterface.php b/typo3/sysext/core/Classes/Security/SigningSecretInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..f20e87342ada9670276ad757e9762405ad96e2fe --- /dev/null +++ b/typo3/sysext/core/Classes/Security/SigningSecretInterface.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * Provides the value that is used as secret in a cryptographic signing process. + * + * @internal + */ +interface SigningSecretInterface +{ + /** + * Returns a public identifier of the secret. + */ + public function getSigningIdentifier(): SecretIdentifier; + + /** + * Returns secret used for signing messages. + */ + public function getSigningSecret(): string; +} diff --git a/typo3/sysext/core/Classes/Security/SigningSecretResolver.php b/typo3/sysext/core/Classes/Security/SigningSecretResolver.php new file mode 100644 index 0000000000000000000000000000000000000000..0884e02656f6f792ac5e3217b24802744ae4ee89 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/SigningSecretResolver.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * Resolves SigningSecretInterface items. + * + * @internal This class with change! + */ +class SigningSecretResolver +{ + /** + * @var array<string, SigningProviderInterface> + */ + protected array $providers; + + /** + * @param array $providers + */ + public function __construct(array $providers) + { + $this->providers = array_filter( + $providers, + static fn ($provider) => $provider instanceof SigningProviderInterface + ); + } + + /** + * Resolves a signing provider by its type (e.g. `NoncePool` from type `'nonce'`) + */ + public function findByType(string $type): ?SigningProviderInterface + { + return $this->providers[$type] ?? null; + } + + /** + * Resolves a specific signing secret by its public identifier + * (e.g. specific `Nonce` from `NoncePool` by given public identifier "nonce:[public-name]") + */ + public function findByIdentifier(SecretIdentifier $identifier): ?SigningSecretInterface + { + if (!isset($this->providers[$identifier->type])) { + return null; + } + return $this->providers[$identifier->type]->findSigningSecret($identifier->name); + } + + /** + * Revokes a specific signing secret. + */ + public function revokeIdentifier(SecretIdentifier $identifier): void + { + if (!isset($this->providers[$identifier->type])) { + return; + } + $this->providers[$identifier->type]->revokeSigningSecret($identifier->name); + } +} diff --git a/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst b/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst new file mode 100644 index 0000000000000000000000000000000000000000..5f34a09d02f626f3c789ef61a69117f7ac616348 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst @@ -0,0 +1,74 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97305-1664100009: + +================================================== +Breaking: #97305 - Introduce CSRF-like login token +================================================== + +See :issue:`97305` + +Description +=========== + +:php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication` requires a +CSRF-like request-token to continue with the authentication process and to +create an actual server-side user session. + +The request-token has to be submitted by one of these ways: + +* HTTP body, e.g. in `<form>` via parameter `__request_token` +* HTTP header, e.g. in XHR via header `X-TYPO3-Request-Token` + +Impact +====== + +Core user authentication is protected by a CSRF-like request-token, to +mitigate `Login CSRF <https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html>`__. + +Custom implementations for login templates or client-side authentication +handling have to be adjusted to submit the required request-token. + + +Affected installations +====================== + +Sites having custom implementations for login templates or client-side authentication. + +Migration +========= + +The :php:`\TYPO3\CMS\Core\Security\RequestToken` signed with a :php:`\TYPO3\CMS\Core\Security\Nonce` +needs to be sent as JSON Web Token (JWT) to the server-side application handling of +the core user authentication process. The scope needs to be :php:`core/user-auth/be` +or :php:`core/user-auth/fe` - depending on whether authentication is applied in +the website's backend or frontend context. + + + +Example for overridden backend login HTML template (`ext:backend`) +------------------------------------------------------------------ + +.. code-block:: diff + + --- a/typo3/sysext/backend/Resources/Private/Layouts/Login.html + +++ b/typo3/sysext/backend/Resources/Private/Layouts/Login.html + <input type="hidden" name="redirect_url" value="{redirectUrl}" /> + <input type="hidden" name="loginRefresh" value="{loginRefresh}" /> + +<input type="hidden" name="{requestTokenName}" value="{requestTokenValue}" /> + +Example for overridden frontend login HTML template (`ext:felogin`) +------------------------------------------------------------------- + +.. code-block:: diff + + --- a/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html + +++ b/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html + -<f:form target="_top" fieldNamePrefix="" action="login"> + +<f:form target="_top" fieldNamePrefix="" action="login" requestToken="{requestToken}"> + +More details are explained in corresponding documentation on +:ref:`Feature #87616: Introduce CSRF-like request-token handling <feature-97305-1664099950>`. + + +.. index:: Backend, Fluid, Frontend, NotScanned, ext:core diff --git a/typo3/sysext/core/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst b/typo3/sysext/core/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst new file mode 100644 index 0000000000000000000000000000000000000000..736dbe4ea61b8672c78a52257e6d85fe4898db7c --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst @@ -0,0 +1,155 @@ +.. include:: /Includes.rst.txt + +.. _feature-97305-1664099950: + +============================================================ +Feature: #97305 - Introduce CSRF-like request-token handling +============================================================ + +See :issue:`97305` + +Description +=========== + +A CSRF-like request-token handling has been introduced to mitigate +potential cross-site requests on actions with side-effects. This approach +does not require an existing server-side user session, but uses a nonce +(number used once) as a "pre-session". The main scope is to ensure a user +actually has visited a page, before submitting data to the web server. + +This token can only be used for HTTP methods `POST`, `PUT` or `PATCH`, but +for instance not for `GET` request. + +New :php:`\TYPO3\CMS\Core\Middleware\RequestTokenMiddleware` resolves +request-tokens and nonce values from a request and enhances responses with +a nonce value in case the underlying application issues one. Both items are +serialized as JSON Web Token (JWT) hash signed with `HS256`. Request-tokens +use the provided nonce value during signing. + +Session cookie names involved for providing the nonce value: + +* `typo3nonce_[hash]` in case request served with plain HTTP +* `__Secure-typo3_nonce` in case request served with secured HTTPS + +Submitting request-token value to application: + +* HTTP body, e.g. in `<form>` via parameter `__request_token` +* HTTP header, e.g. in XHR via header `X-TYPO3-Request-Token` + + +The sequence looks like the following: + +1. Retrieve nonce and request-token values +------------------------------------------ + +This happens on the previous legitimate visit on a page that offers +a corresponding form that shall be protected. The `RequestToken` and `Nonce` +objects (later created implicitly in this example) are organized in new +:php:`\TYPO3\CMS\Core\Context\SecurityAspect`. + +.. code-block:: php + + class MyController + { + protected \TYPO3\CMS\Fluid\View\StandaloneView $view; + protected \TYPO3\CMS\Core\Context\Context $context; + + public function showFormAction() + { + // creating new request-token with scope 'my/process' and hand over to view + $requestToken = \TYPO3\CMS\Core\Security\RequestToken::create('my/process'); + $this->view->assign('requestToken', $requestToken) + // ... + } + + public function processAction() {} + } + +.. code-block:: html + + <!-- in ShowForm.html template: assign request-token object for view-helper --> + <f:form action="process" requestToken="{requestToken}>...</f:form> + +The HTTP response on calling the shown controller-action above will be like this: + +.. code-block:: text + + HTTP/1.1 200 OK + Content-Type: text/html; charset=utf-8 + Set-Cookie: typo3nonce_[hash]=[nonce-as-jwt]; path=/; httponly; samesite=strict + + ... + <form action="/my/process" method="post"> + ... + <input type="hidden" name="__request_token" value="[request-token-as-jwt]"> + ... + </form> + +2. Invoke action request and provide nonce and request-token values +------------------------------------------------------------------- + +When submitting the form and invoking the corresponding action, same-site +cookies `typo3nonce_[hash]` and request-token value `__request_token` are sent +back to the server. Without using a separate nonce in a scope that is protected +by the client, corresponding request-token could be easily extracted from markup +and used without having the possibility to verify the procedural integrity. + +Middleware :php:`\TYPO3\CMS\Core\Middleware\RequestTokenMiddleware` takes care +of providing received nonce and received request-token values in +:php:`\TYPO3\CMS\Core\Context\SecurityAspect`. The handling controller-action +needs to verify that the request-token has the expected `'my/process'` scope. + +.. code-block:: php + + class MyController + { + protected \TYPO3\CMS\Fluid\View\StandaloneView $view; + protected \TYPO3\CMS\Core\Context\Context $context; + + public function showFormAction() {} + + public function processAction() + { + $securityAspect = \TYPO3\CMS\Core\Context\SecurityAspect::provideIn($this->context); + $requestToken = $securityAspect->getReceivedRequestToken(); + + if ($requestToken === null) { + // no request-token was provided in request + // e.g. (overridden) templates need to be adjusted + } elseif ($requestToken === false) { + // there was a request-token, which could not be verified with the nonce + // e.g. when nonce cookie has been overridden by another HTTP request + } elseif ($requestToken->scope !== 'my/process') { + // there was a request-token, but for a different scope + // e.g. when a form with different scope was submitted + } else { + // request-token was valid and for the expected scope + $this->doTheMagic(); + // middleware takes care to remove the the cookie in case no other + // nonce value shall be emitted during the current HTTP request + $requestToken->getSigningSecretIdentifier() !== null) { + $securityAspect->getSigningSecretResolver()->revokeIdentifier( + $requestToken->getSigningSecretIdentifier() + ); + } + } + } + } + + +Impact +====== + +In case a form is protected with the new request-token, actors have to visit the +page containing the form before being able to actually submit data to the +underlying server-side processing. + +When working with multiple browser tabs, an existing nonce value (stored as +session cookie in users' browser) might be overridden. + +The current concept uses a :php:`\TYPO3\CMS\Core\Security\NoncePool` which +supports five different nonces in the same request. The pool purges nonces +15 minutes (900 seconds) after they have been issued. + + +.. index:: Backend, Fluid, Frontend, PHP-API, ext:core diff --git a/typo3/sysext/core/Tests/Unit/Context/SecurityAspectTest.php b/typo3/sysext/core/Tests/Unit/Context/SecurityAspectTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9fb73ce1161a6141ad5f4dd4cf95001c8a270f13 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Context/SecurityAspectTest.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Unit\Context; + +use TYPO3\CMS\Core\Context\SecurityAspect; +use TYPO3\CMS\Core\Security\NoncePool; +use TYPO3\CMS\Core\Security\RequestToken; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class SecurityAspectTest extends UnitTestCase +{ + /** + * @test + */ + public function receivedRequestTokenIsFunctional(): void + { + $aspect = new SecurityAspect(); + self::assertNull($aspect->getReceivedRequestToken()); + $aspect->setReceivedRequestToken(false); + self::assertFalse($aspect->getReceivedRequestToken()); + $token = RequestToken::create(self::class); + $aspect->setReceivedRequestToken($token); + self::assertSame($token, $aspect->getReceivedRequestToken()); + $aspect->setReceivedRequestToken(null); + self::assertNull($aspect->getReceivedRequestToken()); + } + + /** + * @test + */ + public function signingSecretResolverIsFunctional(): void + { + $aspect = new SecurityAspect(); + self::assertInstanceOf(NoncePool::class, $aspect->getSigningSecretResolver()->findByType('nonce')); + } + + /** + * @test + */ + public function noncePoolIsFunctional(): void + { + $aspect = new SecurityAspect(); + self::assertInstanceOf(NoncePool::class, $aspect->getNoncePool()); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Security/NoncePoolTest.php b/typo3/sysext/core/Tests/Unit/Security/NoncePoolTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5435fb6eee34024220962ebcc7d65e7d9294eba7 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/NoncePoolTest.php @@ -0,0 +1,135 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Unit\Security; + +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\CMS\Core\Security\NoncePool; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class NoncePoolTest extends UnitTestCase +{ + /** + * @test + */ + public function instantiationReflectsState(): void + { + $items = self::createItems(); + $validItems = array_slice($items, 0, 3); + $pool = new NoncePool($items); + + foreach ($validItems as $name => $validItem) { + self::assertSame($validItem, $pool->findSigningSecret($name)); + } + self::assertSame(['rejected-name', 'revoked-a', 'revoked-b', 'revoked-c'], $pool->getRevocableNames()); + self::assertSame([], $pool->getEmittableNonces()); + } + + /** + * @test + */ + public function itemsAreMerged(): void + { + $itemsA = self::createItems(); + $itemsB = self::createItems(); + $validItems = array_merge( + array_slice($itemsA, 0, 3), + array_slice($itemsB, 0, 3) + ); + $poolA = new NoncePool($itemsA); + $poolB = new NoncePool($itemsB); + $poolA->merge($poolB); + + foreach ($validItems as $name => $validItem) { + self::assertSame($validItem, $poolA->findSigningSecret($name)); + } + self::assertSame(['rejected-name', 'revoked-a', 'revoked-b', 'revoked-c'], $poolA->getRevocableNames()); + self::assertSame([], $poolA->getEmittableNonces()); + } + + /** + * @test + */ + public function provideSigningSecretDoesNotUseReceivedNonce(): void + { + $items = self::createItems(); + $pool = new NoncePool($items); + $nonceA = $pool->provideSigningSecret(); + $nonceB = $pool->provideSigningSecret(); + self::assertSame($nonceA, $nonceB); + self::assertNotContains($nonceA, $items); + } + + public static function itemsArePurgedDataProvider(): \Generator + { + $items = self::createItems(); + $validItems = array_slice($items, 0, 3); + yield [ + ['size' => 1], + $items, + $validItems, + self::getArrayKeysDiff($items, array_slice($items, 0, 1)), + ]; + yield [ + ['size' => 2], + $items, + $validItems, + self::getArrayKeysDiff($items, array_slice($items, 0, 2)), + ]; + yield [ + ['size' => 10], + $items, + $validItems, + self::getArrayKeysDiff($items, $validItems), + ]; + } + + /** + * @test + * @dataProvider itemsArePurgedDataProvider + */ + public function itemsArePurged(array $options, array $items, array $validItems, array $revocableNames): void + { + $pool = (new NoncePool($items, $options))->purge(); + foreach ($validItems as $name => $validItem) { + self::assertSame($validItem, $pool->findSigningSecret($name)); + } + self::assertEmpty(array_diff($revocableNames, $pool->getRevocableNames())); + } + + private static function createItems(): array + { + $nonceA = Nonce::create(); + $nonceB = Nonce::create(); + $nonceC = Nonce::create(); + return [ + $nonceA->getSigningIdentifier()->name => $nonceA, + $nonceB->getSigningIdentifier()->name => $nonceB, + $nonceC->getSigningIdentifier()->name => $nonceC, + 'rejected-name' => Nonce::create(), + 'revoked-a' => null, + 'revoked-b' => null, + 'revoked-c' => null, + ]; + } + + private static function getArrayKeysDiff(array $items, array $without): array + { + $diff = array_diff_key($items, $without); + return array_keys($diff); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Security/NonceTest.php b/typo3/sysext/core/Tests/Unit/Security/NonceTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e5ba589267ba46523cb5f8f51197dfd507f4299f --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/NonceTest.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Unit\Security; + +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\CMS\Core\Security\NonceException; +use TYPO3\CMS\Core\Utility\StringUtility; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class NonceTest extends UnitTestCase +{ + public function nonceIsCreatedDataProvider(): \Generator + { + yield [0, 40]; + yield [20, 40]; + yield [40, 40]; + yield [60, 60]; + } + + /** + * @test + * @dataProvider nonceIsCreatedDataProvider + */ + public function isCreated(int $length, int $expectedLength): void + { + $nonce = Nonce::create($length); + self::assertSame($expectedLength, strlen($nonce->binary)); + self::assertSame($nonce->b64, StringUtility::base64urlEncode($nonce->binary)); + } + + /** + * @test + */ + public function isCreatedWithProperties(): void + { + $binary = random_bytes(40); + $time = $this->createRandomTime(); + $nonce = new Nonce($binary, $time); + self::assertSame($binary, $nonce->binary); + self::assertEquals($time, $nonce->time); + } + + /** + * @test + */ + public function isEncodedAndDecoded(): void + { + $nonce = Nonce::create(); + $recodedNonce = Nonce::fromHashSignedJwt($nonce->toHashSignedJwt()); + self::assertEquals($recodedNonce, $nonce); + } + + /** + * @test + */ + public function invalidJwtThrowsException(): void + { + $this->expectException(NonceException::class); + $this->expectExceptionCode(1651771351); + Nonce::fromHashSignedJwt('no-jwt-at-all'); + } + + private function createRandomTime(): \DateTimeImmutable + { + // drop microtime, second is the minimum date-interval here + $now = \DateTimeImmutable::createFromFormat( + \DateTimeImmutable::RFC3339, + (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339) + ); + $delta = random_int(-7200, 7200); + $interval = new \DateInterval(sprintf('PT%dS', abs($delta))); + return $delta < 0 ? $now->sub($interval) : $now->add($interval); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Security/RequestTokenTest.php b/typo3/sysext/core/Tests/Unit/Security/RequestTokenTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9e29b3a5bee5412f7d11ca4add40694a5e162b35 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/RequestTokenTest.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Unit\Security; + +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\CMS\Core\Security\RequestToken; +use TYPO3\CMS\Core\Security\RequestTokenException; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class RequestTokenTest extends UnitTestCase +{ + /** + * @test + */ + public function isCreated(): void + { + $scope = $this->createRandomString(); + $token = RequestToken::create($scope); + $now = $this->createCurrentTime(); + self::assertSame($scope, $token->scope); + self::assertEquals($now, $token->time); + self::assertSame([], $token->params); + } + + /** + * @test + */ + public function isCreatedWithProperties(): void + { + $scope = $this->createRandomString(); + $time = $this->createRandomTime(); + $params = ['value' => bin2hex(random_bytes(4))]; + $token = new RequestToken($scope, $time, $params); + self::assertSame($scope, $token->scope); + self::assertEquals($time, $token->time); + self::assertSame($params, $token->params); + } + + /** + * @test + */ + public function paramsAreOverriddenInNewInstance(): void + { + $scope = $this->createRandomString(); + $params = ['nested' => ['value' => bin2hex(random_bytes(4))]]; + $token = RequestToken::create($scope)->withParams(['nested' => ['original' => true]]); + $modifiedToken = $token->withParams($params); + self::assertNotSame($token, $modifiedToken); + self::assertSame($params, $modifiedToken->params); + } + + /** + * @test + */ + public function paramsAreMergedInNewInstance(): void + { + $scope = $this->createRandomString(); + $params = ['nested' => ['value' => bin2hex(random_bytes(4))]]; + $token = RequestToken::create($scope)->withParams(['nested' => ['original' => true]]); + $modifiedToken = $token->withMergedParams($params); + self::assertNotSame($token, $modifiedToken); + self::assertSame(array_merge_recursive($token->params, $params), $modifiedToken->params); + } + + /** + * @test + */ + public function isEncodedAndDecoded(): void + { + $scope = $this->createRandomString(); + $time = $this->createRandomTime(); + $params = ['value' => bin2hex(random_bytes(4))]; + $token = new RequestToken($scope, $time, $params); + + $nonce = Nonce::create(); + $recodedToken = RequestToken::fromHashSignedJwt($token->toHashSignedJwt($nonce), $nonce); + self::assertSame($recodedToken->scope, $token->scope); + self::assertEquals($recodedToken->time, $token->time); + self::assertSame($recodedToken->params, $token->params); + self::assertSame('nonce', $recodedToken->getSigningSecretIdentifier()->type); + self::assertEquals($nonce->getSigningIdentifier(), $recodedToken->getSigningSecretIdentifier()); + } + + /** + * @test + */ + public function invalidJwtThrowsException(): void + { + $nonce = Nonce::create(); + $this->expectException(RequestTokenException::class); + $this->expectExceptionCode(1651771352); + RequestToken::fromHashSignedJwt('no-jwt-at-all', $nonce); + } + + private function createRandomString(): string + { + return bin2hex(random_bytes(4)); + } + + private function createRandomTime(): \DateTimeImmutable + { + $now = $this->createCurrentTime(); + $delta = random_int(-7200, 7200); + $interval = new \DateInterval(sprintf('PT%dS', abs($delta))); + return $delta < 0 ? $now->sub($interval) : $now->add($interval); + } + + private function createCurrentTime(): \DateTimeImmutable + { + // drop microtime, second is the minimum date-interval + return \DateTimeImmutable::createFromFormat( + \DateTimeImmutable::RFC3339, + (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339) + ); + } +} diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json index 2b89acc1a7261a975fd663aa5071c388af556e10..17f2104d330c9be1585d0f8316d029013368425c 100644 --- a/typo3/sysext/core/composer.json +++ b/typo3/sysext/core/composer.json @@ -38,6 +38,7 @@ "doctrine/lexer": "^1.2.3", "egulias/email-validator": "^3.2.1", "enshrined/svg-sanitize": "^0.15.4", + "firebase/php-jwt": "^6.3", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^1.8.5 || ^2.1.2", "lolli42/finediff": "^1.0.2", diff --git a/typo3/sysext/felogin/Classes/Controller/LoginController.php b/typo3/sysext/felogin/Classes/Controller/LoginController.php index 7b321fe510499830f06c72d2042a1ecca9e20edf..ede5349ec1970f5c8e197c5c9f4fe6ce288ac83d 100644 --- a/typo3/sysext/felogin/Classes/Controller/LoginController.php +++ b/typo3/sysext/felogin/Classes/Controller/LoginController.php @@ -21,6 +21,7 @@ use Psr\Http\Message\ResponseInterface; use TYPO3\CMS\Core\Authentication\LoginType; use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Context\UserAspect; +use TYPO3\CMS\Core\Security\RequestToken; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Http\ForwardResponse; use TYPO3\CMS\FrontendLogin\Configuration\RedirectConfiguration; @@ -108,6 +109,7 @@ class LoginController extends AbstractLoginFormController 'redirectReferrer' => $this->request->hasArgument('redirectReferrer') ? (string)$this->request->getArgument('redirectReferrer'): '', 'referer' => $this->requestHandler->getPropertyFromGetAndPost('referer'), 'noRedirect' => $this->isRedirectDisabled(), + 'requestToken' => RequestToken::create('core/user-auth/fe'), ] ); diff --git a/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html b/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html index 4c549c67017f981b2c15a570ea221f1964e74f64..9491676bfb7375ee1effe8d187e1a819c237e03b 100644 --- a/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html +++ b/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html @@ -15,12 +15,12 @@ </f:if> <f:if condition="{onSubmit}"> <f:then> - <f:form target="_top" fieldNamePrefix="" action="login" onsubmit="{onSubmit}"> + <f:form target="_top" fieldNamePrefix="" action="login" onsubmit="{onSubmit}" requestToken="{requestToken}"> <f:render section="content" arguments="{_all}"/> </f:form> </f:then> <f:else> - <f:form target="_top" fieldNamePrefix="" action="login"> + <f:form target="_top" fieldNamePrefix="" action="login" requestToken="{requestToken}"> <f:render section="content" arguments="{_all}"/> </f:form> </f:else> diff --git a/typo3/sysext/fluid/Classes/ViewHelpers/FormViewHelper.php b/typo3/sysext/fluid/Classes/ViewHelpers/FormViewHelper.php index 749ea65ec6f994da343bd60430dd2674a1a0c6cf..f76f9119e030a8ea41373ef6367376ca3f7e9d64 100644 --- a/typo3/sysext/fluid/Classes/ViewHelpers/FormViewHelper.php +++ b/typo3/sysext/fluid/Classes/ViewHelpers/FormViewHelper.php @@ -18,7 +18,10 @@ declare(strict_types=1); namespace TYPO3\CMS\Fluid\ViewHelpers; use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\SecurityAspect; use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Security\RequestToken; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; use TYPO3\CMS\Extbase\Mvc\Controller\MvcPropertyMappingConfigurationService; @@ -124,6 +127,8 @@ class FormViewHelper extends AbstractFormViewHelper $this->registerArgument('actionUri', 'string', 'can be used to overwrite the "action" attribute of the form tag'); $this->registerArgument('objectName', 'string', 'name of the object that is bound to this form. If this argument is not specified, the name attribute of this form is used to determine the FormObjectName'); $this->registerArgument('hiddenFieldClassName', 'string', 'hiddenFieldClassName'); + $this->registerArgument('requestToken', 'mixed', 'whether to add that request token to the form'); + $this->registerArgument('signingType', 'string', 'which signing type to be used on the request token (falls back to "nonce")'); $this->registerTagAttribute('enctype', 'string', 'MIME type with which the form is submitted'); $this->registerTagAttribute('method', 'string', 'Transfer type (GET or POST)'); $this->registerTagAttribute('name', 'string', 'Name of form'); @@ -161,6 +166,7 @@ class FormViewHelper extends AbstractFormViewHelper $this->addFormObjectToViewHelperVariableContainer(); $this->addFieldNamePrefixToViewHelperVariableContainer(); $this->addFormFieldNamesToViewHelperVariableContainer(); + $formContent = $this->renderChildren(); if (isset($this->arguments['hiddenFieldClassName']) && $this->arguments['hiddenFieldClassName'] !== null) { @@ -172,6 +178,7 @@ class FormViewHelper extends AbstractFormViewHelper $content .= $this->renderHiddenIdentityField($this->arguments['object'] ?? null, $this->getFormObjectName()); $content .= $this->renderAdditionalIdentityFields(); $content .= $this->renderHiddenReferrerFields(); + $content .= $this->renderRequestTokenHiddenField(); // Render the trusted list of all properties after everything else has been rendered $content .= $this->renderTrustedPropertiesField(); @@ -444,4 +451,47 @@ class FormViewHelper extends AbstractFormViewHelper $requestHash = $this->mvcPropertyMappingConfigurationService->generateTrustedPropertiesToken($formFieldNames, $this->getFieldNamePrefix()); return '<input type="hidden" name="' . htmlspecialchars($this->prefixFieldName('__trustedProperties')) . '" value="' . htmlspecialchars($requestHash) . '" />'; } + + protected function renderRequestTokenHiddenField(): string + { + $requestToken = $this->arguments['requestToken'] ?? null; + $signingType = $this->arguments['signingType'] ?? null; + + $isTrulyRequestToken = is_int($requestToken) && $requestToken === 1 + || is_string($requestToken) && strtolower($requestToken) === 'true'; + $formAction = $this->tag->getAttribute('action'); + + // basically "request token, yes" - uses form-action URI as scope + if ($isTrulyRequestToken || $requestToken === '@nonce') { + $requestToken = RequestToken::create($formAction); + // basically "request token with 'my-scope'" - uses 'my-scope' + } elseif (is_string($requestToken) && $requestToken !== '') { + $requestToken = RequestToken::create($requestToken); + } + if (!$requestToken instanceof RequestToken) { + return ''; + } + if (strtolower((string)($this->arguments['method'] ?? '')) === 'get') { + throw new \LogicException('Cannot apply request token for forms sent via HTTP GET', 1651775963); + } + + $context = GeneralUtility::makeInstance(Context::class); + $securityAspect = SecurityAspect::provideIn($context); + // @todo currently defaults to 'nonce', there might be a better strategy in the future + $signingType = $signingType ?: 'nonce'; + $signingProvider = $securityAspect->getSigningSecretResolver()->findByType($signingType); + if ($signingProvider === null) { + throw new \LogicException(sprintf('Cannot find request token signing type "%s"', $signingType), 1664260307); + } + + $signingSecret = $signingProvider->provideSigningSecret(); + $requestToken = $requestToken->withMergedParams(['request' => ['uri' => $formAction]]); + + $attrs = [ + 'type' => 'hidden', + 'name' => RequestToken::PARAM_NAME, + 'value' => $requestToken->toHashSignedJwt($signingSecret), + ]; + return '<input ' . GeneralUtility::implodeAttributes($attrs, true) . '/>'; + } } diff --git a/typo3/sysext/frontend/Configuration/RequestMiddlewares.php b/typo3/sysext/frontend/Configuration/RequestMiddlewares.php index a08fe5ed25fca7a45b99e54ec5aefbb2e94b8031..14a9ae4bb0b637ab99a4cb226c4a2969b5441a5f 100644 --- a/typo3/sysext/frontend/Configuration/RequestMiddlewares.php +++ b/typo3/sysext/frontend/Configuration/RequestMiddlewares.php @@ -90,6 +90,16 @@ return [ 'typo3/cms-frontend/page-resolver', ], ], + 'typo3/cms-core/request-token-middleware' => [ + 'target' => \TYPO3\CMS\Core\Middleware\RequestTokenMiddleware::class, + 'after' => [ + 'typo3/cms-frontend/site', + ], + 'before' => [ + 'typo3/cms-frontend/backend-user-authentication', + 'typo3/cms-frontend/authentication', + ], + ], 'typo3/cms-frontend/backend-user-authentication' => [ 'target' => \TYPO3\CMS\Frontend\Middleware\BackendUserAuthenticator::class, 'before' => [ diff --git a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php index b3242f10a847e010996a6f47b75243274e863ba5..2226dc9bb1ba48061bd5e63ed2cf8ae3924ec5d3 100644 --- a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php +++ b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php @@ -24,11 +24,13 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Log\NullLogger; use TYPO3\CMS\Core\Authentication\AuthenticationService; use TYPO3\CMS\Core\Authentication\IpLocker; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\SecurityAspect; 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\Request; use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Security\RequestToken; use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface; use TYPO3\CMS\Core\Session\UserSession; use TYPO3\CMS\Core\Session\UserSessionManager; @@ -272,6 +274,12 @@ class FrontendUserAuthenticationTest extends UnitTestCase { $GLOBALS['BE_USER'] = []; + // provide request-token + $context = GeneralUtility::makeInstance(Context::class); + $securityAspect = SecurityAspect::provideIn($context); + $requestToken = RequestToken::create('core/user-auth/fe'); + $securityAspect->setReceivedRequestToken($requestToken); + // Main session backend setup $userSession = UserSession::createNonFixated('newSessionId'); $elevatedUserSession = UserSession::createFromRecord('newSessionId', ['ses_userid' => 1], true); @@ -311,6 +319,6 @@ class FrontendUserAuthenticationTest extends UnitTestCase // We need to wrap the array to something thats is \Traversable, in PHP 7.1 we can use traversable pseudo type instead $subject->method('getAuthServices')->willReturn(new \ArrayIterator([$authServiceMock])); $subject->start($this->prophesize(ServerRequestInterface::class)->reveal()); - self::assertEquals('existingUserName', $subject->user['username']); + self::assertEquals('existingUserName', $subject->user['username'] ?? null); } }