TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -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 : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user