TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:17 +02:00
commit 629541cb4c
86 changed files with 5360 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
<?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\FrontendLogin\Controller;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Authentication\LoginType;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Security\RequestToken;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Http\ForwardResponse;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\FrontendLogin\Configuration\RedirectConfiguration;
use TYPO3\CMS\FrontendLogin\Event\BeforeRedirectEvent;
use TYPO3\CMS\FrontendLogin\Event\LoginConfirmedEvent;
use TYPO3\CMS\FrontendLogin\Event\LoginErrorOccurredEvent;
use TYPO3\CMS\FrontendLogin\Event\LogoutConfirmedEvent;
use TYPO3\CMS\FrontendLogin\Event\ModifyLoginFormViewEvent;
use TYPO3\CMS\FrontendLogin\Redirect\RedirectHandler;
/**
* Used for plugin login
*
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
class LoginController extends ActionController
{
public const MESSAGEKEY_DEFAULT = 'welcome';
public const MESSAGEKEY_ERROR = 'error';
public const MESSAGEKEY_LOGOUT = 'logout';
protected string $loginType = '';
protected string $redirectUrl = '';
protected RedirectConfiguration $configuration;
public function __construct(
protected readonly RedirectHandler $redirectHandler,
protected readonly Context $context,
protected readonly PageRepository $pageRepository
) {}
/**
* Initialize redirects
*/
public function initializeAction(): void
{
$this->loginType = (string)($this->request->getParsedBody()['logintype'] ?? $this->request->getQueryParams()['logintype'] ?? '');
$this->configuration = RedirectConfiguration::fromSettings($this->settings);
if ($this->isLoginOrLogoutInProgress() && !$this->isRedirectDisabled()) {
$this->redirectUrl = $this->redirectHandler->processRedirect(
$this->request,
$this->loginType,
$this->configuration,
$this->request->hasArgument('redirectReferrer') ? $this->request->getArgument('redirectReferrer') : ''
);
}
}
/**
* Show login form
*/
public function loginAction(): ResponseInterface
{
if ($this->isLogoutSuccessful()) {
$this->eventDispatcher->dispatch(new LogoutConfirmedEvent($this, $this->view, $this->request));
} elseif ($this->hasLoginErrorOccurred()) {
$this->eventDispatcher->dispatch(new LoginErrorOccurredEvent($this->request));
}
if (($forwardResponse = $this->handleLoginForwards()) !== null) {
return $forwardResponse;
}
if (($redirectResponse = $this->handleRedirect()) !== null) {
return $redirectResponse;
}
$this->eventDispatcher->dispatch(new ModifyLoginFormViewEvent($this->view, $this->request));
$storagePageIds = ($GLOBALS['TYPO3_CONF_VARS']['FE']['checkFeUserPid'] ?? false)
? $this->pageRepository->getPageIdsRecursive(GeneralUtility::intExplode(',', (string)($this->settings['pages'] ?? ''), true), (int)($this->settings['recursive'] ?? 0))
: [];
$this->view->assignMultiple(
[
'messageKey' => $this->getStatusMessageKey(),
'permaloginStatus' => $this->getPermaloginStatus(),
'redirectURL' => $this->redirectHandler->getLoginFormRedirectUrl($this->request, $this->configuration, $this->isRedirectDisabled()),
'redirectReferrer' => $this->request->hasArgument('redirectReferrer') ? (string)$this->request->getArgument('redirectReferrer') : '',
'referer' => $this->redirectHandler->getReferrerForLoginForm($this->request, $this->settings),
'noRedirect' => $this->isRedirectDisabled(),
'requestToken' => RequestToken::create('core/user-auth/fe')
->withMergedParams(['pid' => implode(',', $storagePageIds)]),
]
);
return $this->htmlResponse();
}
/**
* User overview for logged in users
*/
public function overviewAction(bool $showLoginMessage = false): ResponseInterface
{
if (!$this->context->getAspect('frontend.user')->isLoggedIn()) {
return new ForwardResponse('login');
}
$this->eventDispatcher->dispatch(new LoginConfirmedEvent($this, $this->view, $this->request));
if (($redirectResponse = $this->handleRedirect()) !== null) {
return $redirectResponse;
}
$this->view->assignMultiple(
[
'user' => $this->request->getAttribute('frontend.user')->user,
'showLoginMessage' => $showLoginMessage,
]
);
return $this->htmlResponse();
}
/**
* Show logout form. Note, that this action should never process any redirects.
*/
public function logoutAction(): ResponseInterface
{
$this->view->assignMultiple(
[
'user' => $this->request->getAttribute('frontend.user')->user,
'noRedirect' => $this->isRedirectDisabled(),
]
);
return $this->htmlResponse();
}
/**
* Handles the redirect when $this->redirectUrl is not empty
*/
protected function handleRedirect(): ?ResponseInterface
{
if ($this->redirectUrl !== '') {
$event = new BeforeRedirectEvent($this->loginType, $this->redirectUrl, $this->request);
$this->eventDispatcher->dispatch($event);
if ($event->getRedirectUrl() !== '') {
return $this->redirectToUri($event->getRedirectUrl());
}
}
return null;
}
/**
* Handle forwards to overview and logout actions from login action
*/
protected function handleLoginForwards(): ?ResponseInterface
{
if ($this->shouldRedirectToOverview()) {
return (new ForwardResponse('overview'))->withArguments(['showLoginMessage' => true]);
}
if ($this->context->getAspect('frontend.user')->isLoggedIn()) {
return new ForwardResponse('logout');
}
return null;
}
/**
* The permanent login checkbox should only be shown if permalogin is not deactivated (-1),
* not forced to be always active (2) and lifetime is greater than 0
*/
protected function getPermaloginStatus(): int
{
$permaLogin = (int)$GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin'];
return $this->isPermaloginDisabled($permaLogin) ? -1 : $permaLogin;
}
protected function isPermaloginDisabled(int $permaLogin): bool
{
return $permaLogin > 1
|| (int)($this->settings['showPermaLogin'] ?? 0) === 0
|| $GLOBALS['TYPO3_CONF_VARS']['FE']['lifetime'] === 0;
}
/**
* Redirect to overview on login successful and setting showLogoutFormAfterLogin disabled
*/
protected function shouldRedirectToOverview(): bool
{
return $this->context->getAspect('frontend.user')->isLoggedIn()
&& (LoginType::tryFrom($this->loginType) === LoginType::LOGIN)
&& !($this->settings['showLogoutFormAfterLogin'] ?? 0);
}
/**
* Return message key based on user login status
*/
protected function getStatusMessageKey(): string
{
$messageKey = self::MESSAGEKEY_DEFAULT;
if ($this->hasLoginErrorOccurred()) {
$messageKey = self::MESSAGEKEY_ERROR;
} elseif (LoginType::tryFrom($this->loginType) === LoginType::LOGOUT) {
$messageKey = self::MESSAGEKEY_LOGOUT;
}
return $messageKey;
}
protected function isLoginOrLogoutInProgress(): bool
{
$type = LoginType::tryFrom($this->loginType);
return $type === LoginType::LOGIN || $type === LoginType::LOGOUT;
}
/**
* Is redirect disabled by setting or noredirect GET/POST parameter
*/
protected function isRedirectDisabled(): bool
{
return
(int)($this->request->getParsedBody()['noredirect'] ?? $this->request->getQueryParams()['noredirect'] ?? 0) === 1
|| ($this->settings['noredirect'] ?? false)
|| ($this->settings['redirectDisable'] ?? false);
}
protected function isLogoutSuccessful(): bool
{
return LoginType::tryFrom($this->loginType) === LoginType::LOGOUT && !$this->context->getAspect('frontend.user')->isLoggedIn();
}
protected function hasLoginErrorOccurred(): bool
{
return LoginType::tryFrom($this->loginType) === LoginType::LOGIN && !$this->context->getAspect('frontend.user')->isLoggedIn();
}
}
@@ -0,0 +1,314 @@
<?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\FrontendLogin\Controller;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\Exception\AspectNotFoundException;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\PasswordPolicy\Event\EnrichPasswordValidationContextDataEvent;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyValidator;
use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData;
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactoryInterface;
use TYPO3\CMS\Core\Session\SessionManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Error\Error;
use TYPO3\CMS\Extbase\Error\Result;
use TYPO3\CMS\Extbase\Http\ForwardResponse;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Mvc\Exception\NoSuchArgumentException;
use TYPO3\CMS\Extbase\Mvc\ExtbaseRequestParameters;
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
use TYPO3\CMS\FrontendLogin\Configuration\RecoveryConfiguration;
use TYPO3\CMS\FrontendLogin\Domain\Repository\FrontendUserRepository;
use TYPO3\CMS\FrontendLogin\Event\PasswordChangeEvent;
use TYPO3\CMS\FrontendLogin\Service\RecoveryService;
/**
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
class PasswordRecoveryController extends ActionController
{
public function __construct(
protected RecoveryService $recoveryService,
protected FrontendUserRepository $userRepository,
protected RecoveryConfiguration $recoveryConfiguration,
protected readonly Features $features,
protected readonly PageRepository $pageRepository,
protected RateLimiterFactoryInterface $rateLimiterFactory
) {}
/**
* Shows the recovery form. If $userIdentifier is set, an email will be sent, if the corresponding user exists and
* has a valid email address set.
*/
public function recoveryAction(?string $userIdentifier = null): ResponseInterface
{
if (empty($userIdentifier)) {
return $this->htmlResponse();
}
$storagePageIds = ($GLOBALS['TYPO3_CONF_VARS']['FE']['checkFeUserPid'] ?? false)
? $this->pageRepository->getPageIdsRecursive(GeneralUtility::intExplode(',', (string)($this->settings['pages'] ?? ''), true), (int)($this->settings['recursive'] ?? 0))
: [];
$userData = $this->userRepository->findUserByUsernameOrEmailOnPages($userIdentifier, $storagePageIds);
if ($userData
&& GeneralUtility::validEmail($userData['email'])
&& !$this->hasExceededMaximumAttemptsForReset($userData['email'])
) {
$hash = $this->recoveryConfiguration->getForgotHash();
$this->userRepository->updateForgotHashForUserByUid($userData['uid'], $this->hashService->hmac($hash, self::class, HashAlgo::SHA3_256));
$this->recoveryService->sendRecoveryEmail($this->request, $userData, $hash);
}
// Prevent time based information disclosure by waiting a random time before sending a response. This prevents
// that the response time can be an indicator if the used username or email exists or not. Wait a random time
// between 200 milliseconds and 3 seconds.
usleep(random_int(200000, 3000000));
// Always show the default message and never notify about a potential rate limit, because this would reveal,
// that a given user identifier is actually valid.
$this->addFlashMessage($this->getTranslation('forgot_reset_message_emailSent'));
return $this->redirect('login', 'Login', 'felogin');
}
protected function hasExceededMaximumAttemptsForReset(string $email): bool
{
$limiter = $this->rateLimiterFactory->create($email);
$limit = $limiter->consume();
return !$limit->isAccepted();
}
/**
* Validate the hash argument and make sure that:
*
* - it is in the expected format
* - it is not expired
* - a fe_user with the given hash exists
*
* If one of the checks fail, a redirect response to the recoveryAction() is returned
*/
protected function validateHashArgument(): ?ResponseInterface
{
$hash = $this->request->hasArgument('hash') ? $this->request->getArgument('hash') : '';
$hash = is_string($hash) ? $hash : '';
if (!$this->validateHashFormat($hash)) {
return $this->redirect('recovery', 'PasswordRecovery', 'felogin');
}
$timestamp = (int)GeneralUtility::trimExplode('|', $hash)[0];
$currentTimestamp = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp');
// timestamp is expired or hash can not be assigned to a user
if ($currentTimestamp > $timestamp || !$this->userRepository->existsUserWithHash($this->hashService->hmac($hash, self::class, HashAlgo::SHA3_256))) {
/** @var ExtbaseRequestParameters $extbaseRequestParameters */
$extbaseRequestParameters = clone $this->request->getAttribute('extbase');
$originalResult = $extbaseRequestParameters->getOriginalRequestMappingResults();
$originalResult->addError(new Error($this->getTranslation('change_password_notvalid_message'), 1554994253));
$extbaseRequestParameters->setOriginalRequestMappingResults($originalResult);
$this->request = $this->request->withAttribute('extbase', $extbaseRequestParameters);
return (new ForwardResponse('recovery'))
->withControllerName('PasswordRecovery')
->withExtensionName('felogin')
->withArgumentsValidationResult($originalResult);
}
return null;
}
/**
* Show the change password form if a valid hash is available.
*/
public function showChangePasswordAction(string $hash = ''): ResponseInterface
{
// Validate hash (lifetime, format and fe_user with hash persistence)
if (($response = $this->validateHashArgument()) instanceof ResponseInterface) {
return $response;
}
$this->view->assignMultiple([
'hash' => $hash,
'passwordRequirements' => $this->getPasswordPolicyValidator()->getRequirements(),
]);
return $this->htmlResponse();
}
/**
* Validates the hash argument, the entered password and passwordRepeat values. If one of the values is considered
* as invalid, a response object with validation errors in the mapping results is returned.
*
* @throws NoSuchArgumentException
*/
public function validateHashAndPasswords()
{
// Validate hash (lifetime, format and fe_user with hash persistence)
if (($response = $this->validateHashArgument()) instanceof ResponseInterface) {
return $response;
}
// Exit early if newPass or newPassRepeat is not set.
/** @var ExtbaseRequestParameters $extbaseRequestParameters */
$extbaseRequestParameters = clone $this->request->getAttribute('extbase');
$originalResult = $extbaseRequestParameters->getOriginalRequestMappingResults();
$argumentsExist = $this->request->hasArgument('newPass') && $this->request->hasArgument('newPassRepeat');
$argumentsEmpty = empty($this->request->getArgument('newPass')) || empty($this->request->getArgument('newPassRepeat'));
if (!$argumentsExist || $argumentsEmpty) {
$originalResult->addError(new Error(
$this->getTranslation('empty_password_and_password_repeat'),
1554971665
));
return (new ForwardResponse('showChangePassword'))
->withControllerName('PasswordRecovery')
->withExtensionName('felogin')
->withArguments(['hash' => $this->request->getArgument('hash')])
->withArgumentsValidationResult($originalResult);
}
$this->validateNewPassword($originalResult);
// if an error exists, forward with all messages to the change password form
if ($originalResult->hasErrors()) {
return (new ForwardResponse('showChangePassword'))
->withControllerName('PasswordRecovery')
->withExtensionName('felogin')
->withArguments(['hash' => $this->request->getArgument('hash')])
->withArgumentsValidationResult($originalResult);
}
}
/**
* Change actual password. Hash $newPass and update the user with the corresponding $hash.
*
* @throws AspectNotFoundException
* @throws InvalidPasswordHashException
*/
public function changePasswordAction(string $newPass, string $hash): ResponseInterface
{
if (($response = $this->validateHashAndPasswords()) instanceof ResponseInterface) {
return $response;
}
$hashedPassword = GeneralUtility::makeInstance(PasswordHashFactory::class)
->getDefaultHashInstance('FE')
->getHashedPassword($newPass);
$hmac = $this->hashService->hmac($hash, self::class, HashAlgo::SHA3_256);
$user = $this->userRepository->findOneByForgotPasswordHash($hmac);
$event = new PasswordChangeEvent($user, $hashedPassword, $newPass, $this->request);
$this->eventDispatcher->dispatch($event);
$this->userRepository->updatePasswordAndInvalidateHash($hmac, $hashedPassword);
$this->invalidateUserSessions($user['uid']);
$this->addFlashMessage($this->getTranslation('change_password_done_message'));
return $this->redirect('login', 'Login', 'felogin', ['redirectReferrer' => 'off']);
}
/**
* @throws NoSuchArgumentException
*/
protected function validateNewPassword(Result $originalResult): void
{
$newPass = $this->request->getArgument('newPass');
// make sure the user entered the password twice
if ($newPass !== $this->request->getArgument('newPassRepeat')) {
$originalResult->addError(new Error($this->getTranslation('password_must_match_repeated'), 1554912163));
}
$hash = $this->request->getArgument('hash');
$userData = $this->userRepository->findOneByForgotPasswordHash($this->hashService->hmac($hash, self::class, HashAlgo::SHA3_256));
// Validate against password policy
$passwordPolicyValidator = $this->getPasswordPolicyValidator();
$contextData = new ContextData(
loginMode: 'FE',
currentPasswordHash: $userData['password']
);
$contextData->setData('currentUsername', $userData['username']);
$contextData->setData('currentFirstname', $userData['first_name']);
$contextData->setData('currentLastname', $userData['last_name']);
$event = $this->eventDispatcher->dispatch(
new EnrichPasswordValidationContextDataEvent(
$contextData,
$userData,
self::class
)
);
$contextData = $event->getContextData();
if (!$passwordPolicyValidator->isValidPassword($newPass, $contextData)) {
foreach ($passwordPolicyValidator->getValidationErrors() as $validationError) {
$validationResult = new Result();
$validationResult->addError(new Error($validationError, 1667647475));
$originalResult->merge($validationResult);
}
}
}
/**
* Wrapper to mock LocalizationUtility::translate
*/
protected function getTranslation(string $key): string
{
return (string)LocalizationUtility::translate($key, 'felogin');
}
/**
* Validates that $hash is in the expected format (timestamp|forgot_hash)
*/
protected function validateHashFormat(string $hash): bool
{
return !empty($hash) && strpos($hash, '|') === 10;
}
/**
* Invalidate all frontend user sessions by given user id
*/
protected function invalidateUserSessions(int $userId): void
{
$sessionManager = GeneralUtility::makeInstance(SessionManager::class);
$sessionBackend = $sessionManager->getSessionBackend('FE');
$sessionManager->invalidateAllSessionsByUserId($sessionBackend, $userId);
}
protected function getPasswordPolicyValidator(): PasswordPolicyValidator
{
$passwordPolicy = $GLOBALS['TYPO3_CONF_VARS']['FE']['passwordPolicy'] ?? 'default';
return GeneralUtility::makeInstance(
PasswordPolicyValidator::class,
PasswordPolicyAction::UPDATE_USER_PASSWORD,
is_string($passwordPolicy) ? $passwordPolicy : ''
);
}
}