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
@@ -0,0 +1,23 @@
<?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\Configuration;
/**
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
class IncompleteConfigurationException extends \Exception {}
@@ -0,0 +1,140 @@
<?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\Configuration;
use Symfony\Component\Mime\Address;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
/**
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
class RecoveryConfiguration
{
protected readonly string $forgotHash;
protected ?Address $replyTo = null;
protected Address $sender;
protected readonly array $settings;
protected string $mailTemplateName = '';
protected ?int $timestamp = null;
public function __construct(
protected readonly Context $context,
ConfigurationManagerInterface $configurationManager,
Random $random,
HashService $hashService
) {
$this->settings = $configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_SETTINGS);
$this->forgotHash = $this->getLifeTimeTimestamp() . '|' . $this->generateHash($random, $hashService);
$this->resolveFromTypoScript();
}
/**
* Returns the forgot hash.
*/
public function getForgotHash(): string
{
return $this->forgotHash;
}
/**
* Returns email template name configured in TypoScript
*/
public function getMailTemplateName(): string
{
return $this->mailTemplateName;
}
/**
* Returns TTL timestamp of the forgot hash
*/
public function getLifeTimeTimestamp(): int
{
if ($this->timestamp === null) {
$lifetimeInHours = (int)($this->settings['forgotLinkHashValidTime'] ?? 0) ?: 12;
$currentTimestamp = $this->context->getPropertyFromAspect('date', 'timestamp');
$this->timestamp = $currentTimestamp + 3600 * $lifetimeInHours;
}
return $this->timestamp;
}
/**
* Returns reply-to address if configured otherwise null.
*/
public function getReplyTo(): ?Address
{
return $this->replyTo;
}
/**
* Returns the sender. Normally the current typo3 installation.
*/
public function getSender(): Address
{
return $this->sender;
}
protected function generateHash(Random $random, HashService $hashService): string
{
$randomString = $random->generateRandomHexString(16);
return $hashService->hmac($randomString, self::class, HashAlgo::SHA3_256);
}
protected function resolveFromTypoScript(): void
{
$fromAddress = ($this->settings['email_from'] ?? null) ?: $GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailFromAddress'];
if (empty($fromAddress)) {
throw new IncompleteConfigurationException(
'Either "$GLOBALS[\'TYPO3_CONF_VARS\'][\'MAIL\'][\'defaultMailFromAddress\']" or extension key "plugin.tx_felogin_login.settings.email_from" cannot be empty!',
1573825624
);
}
$fromName = ($this->settings['email_fromName'] ?? null) ?: $GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailFromName'];
if (empty($fromName)) {
throw new IncompleteConfigurationException(
'Either "$GLOBALS[\'TYPO3_CONF_VARS\'][\'MAIL\'][\'defaultMailFromName\']" or extension key "plugin.tx_felogin_login.settings.email_fromName" cannot be empty!',
1573825625
);
}
$this->sender = new Address($fromAddress, $fromName);
if (!empty($GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailReplyToAddress'])) {
if ($GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailReplyToName']) {
$this->replyTo = new Address(
$GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailReplyToAddress'],
$GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailReplyToName']
);
} else {
$this->replyTo = new Address(
$GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailReplyToAddress']
);
}
}
$this->mailTemplateName = (string)($this->settings['email']['templateName'] ?? '');
if (empty($this->mailTemplateName)) {
throw new IncompleteConfigurationException(
'Key "plugin.tx_felogin_login.settings.email.templateName" cannot be empty! Ensure that TypoScript is properly included.',
1584998393
);
}
}
}
@@ -0,0 +1,86 @@
<?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\Configuration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* A class that holds and manages all states relevant for handling redirects
*
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
class RedirectConfiguration
{
protected array $modes;
public function __construct(
array|string|null $mode,
protected string $firstMode,
protected int $pageOnLogin,
protected string $domains,
protected int $pageOnLoginError,
protected int $pageOnLogout
) {
$this->modes = is_array($mode) ? $mode : GeneralUtility::trimExplode(',', $mode ?? '', true);
}
public function getModes(): array
{
return $this->modes;
}
public function getFirstMode(): string
{
return $this->firstMode;
}
public function getPageOnLogin(): int
{
return $this->pageOnLogin;
}
public function getDomains(): string
{
return $this->domains;
}
public function getPageOnLoginError(): int
{
return $this->pageOnLoginError;
}
public function getPageOnLogout(): int
{
return $this->pageOnLogout;
}
/**
* Factory when creating a configuration out of Extbase / plugin settings.
*/
public static function fromSettings(array $settings): self
{
return new RedirectConfiguration(
($settings['redirectMode'] ?? ''),
(string)($settings['redirectFirstMethod'] ?? ''),
(int)($settings['redirectPageLogin'] ?? 0),
(string)($settings['domains'] ?? ''),
(int)($settings['redirectPageLoginError'] ?? 0),
(int)($settings['redirectPageLogout'] ?? 0)
);
}
}
+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 : ''
);
}
}
@@ -0,0 +1,50 @@
<?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\Domain\Repository;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
class FrontendUserGroupRepository
{
protected readonly Connection $connection;
public function __construct(ConnectionPool $connectionPool)
{
$this->connection = $connectionPool->getConnectionForTable('fe_groups');
}
public function findRedirectPageIdByGroupId(int $groupId): ?int
{
$queryBuilder = $this->connection->createQueryBuilder();
$queryBuilder->getRestrictions()->removeAll();
$query = $queryBuilder
->select('felogin_redirectPid')
->from('fe_groups')
->where(
$queryBuilder->expr()->neq('felogin_redirectPid', $this->connection->quote('')),
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($groupId, Connection::PARAM_INT))
)
->setMaxResults(1);
$column = $query->executeQuery()->fetchOne();
return $column === false ? null : (int)$column;
}
}
@@ -0,0 +1,141 @@
<?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\Domain\Repository;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
readonly class FrontendUserRepository
{
protected Connection $connection;
public function __construct(
protected Context $context,
ConnectionPool $connectionPool,
) {
$this->connection = $connectionPool->getConnectionForTable('fe_users');
}
/**
* Change the password for a user based on forgot password hash.
*/
public function updatePasswordAndInvalidateHash(string $forgotPasswordHash, string $hashedPassword): void
{
$queryBuilder = $this->connection->createQueryBuilder();
$currentTimestamp = $this->context->getPropertyFromAspect('date', 'timestamp');
$query = $queryBuilder
->update('fe_users')
->set('password', $hashedPassword)
->set('felogin_forgotHash', $this->connection->quote(''), false)
->set('tstamp', $currentTimestamp)
->where($queryBuilder->expr()->eq('felogin_forgotHash', $queryBuilder->createNamedParameter($forgotPasswordHash)));
$query->executeStatement();
}
/**
* Returns true if a user exists with hash as `felogin_forgothash`, otherwise false.
*/
public function existsUserWithHash(string $hash): bool
{
$queryBuilder = $this->connection->createQueryBuilder();
$query = $queryBuilder
->count('uid')
->from('fe_users')
->where($queryBuilder->expr()->eq('felogin_forgotHash', $queryBuilder->createNamedParameter($hash)));
return (bool)$query->executeQuery()->fetchOne();
}
/**
* Sets forgot hash for passed user uid.
*/
public function updateForgotHashForUserByUid(int $uid, string $hash): void
{
$queryBuilder = $this->connection->createQueryBuilder();
$query = $queryBuilder
->update('fe_users')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, $this->connection::PARAM_INT)))
->set('felogin_forgotHash', $hash);
$query->executeStatement();
}
/**
* Fetches an array with all columns (except the password) from the fe_users table for the given username or
* email on the given pages. Returns null, if user was not found or if user has no email address set.
*/
public function findUserByUsernameOrEmailOnPages(string $usernameOrEmail, array $pages = []): ?array
{
if ($usernameOrEmail === '') {
return null;
}
$queryBuilder = $this->connection->createQueryBuilder();
$query = $queryBuilder
->select('*')
->from('fe_users')
->where(
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('username', $queryBuilder->createNamedParameter($usernameOrEmail)),
$queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($usernameOrEmail)),
),
$queryBuilder->expr()->neq('email', $this->connection->quote('')),
);
if (!empty($pages)) {
// respect storage pid
$query->andWhere($queryBuilder->expr()->in('pid', $pages));
}
$result = $query->executeQuery()->fetchAssociative() ?: null;
if ($result) {
unset($result['password']);
}
return $result;
}
public function findOneByForgotPasswordHash(string $hash): ?array
{
if ($hash === '') {
return null;
}
$queryBuilder = $this->connection->createQueryBuilder();
$query = $queryBuilder
->select('*')
->from('fe_users')
->where($queryBuilder->expr()->eq('felogin_forgotHash', $queryBuilder->createNamedParameter($hash)))
->setMaxResults(1);
$row = $query->executeQuery()->fetchAssociative();
return is_array($row) ? $row : null;
}
public function findRedirectIdPageByUserId(int $uid): ?int
{
$queryBuilder = $this->connection->createQueryBuilder();
$queryBuilder->getRestrictions()->removeAll();
$query = $queryBuilder
->select('felogin_redirectPid')
->from('fe_users')
->where(
$queryBuilder->expr()->neq('felogin_redirectPid', $this->connection->quote('')),
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))
)
->setMaxResults(1);
$column = $query->executeQuery()->fetchOne();
return $column === false ? null : (int)$column;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\View\ViewInterface;
use TYPO3\CMS\FrontendLogin\Controller\LoginController;
/**
* A confirmation notification when an login/logout action has successfully arrived at the plugin, via the view and the controller, multiple
* information can be overridden in Event Listeners.
*/
abstract class AbstractConfirmedEvent
{
public function __construct(
protected readonly LoginController $controller,
private ViewInterface $view,
protected readonly ServerRequestInterface $request
) {}
public function getController(): LoginController
{
return $this->controller;
}
public function getView(): ViewInterface
{
return $this->view;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Notification before a redirect is made, which also allows to modify
* the actual redirect URL. Setting the redirect to an empty string
* will avoid triggering a redirect.
*/
final class BeforeRedirectEvent
{
public function __construct(
private readonly string $loginType,
private string $redirectUrl,
private readonly ServerRequestInterface $request,
) {}
public function getLoginType(): string
{
return $this->loginType;
}
public function getRedirectUrl(): string
{
return $this->redirectUrl;
}
public function setRedirectUrl(string $redirectUrl): void
{
$this->redirectUrl = $redirectUrl;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?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\Event;
/**
* A notification when a log in has successfully arrived at the plugin, via the view and the controller, multiple
* information can be overridden in Event Listeners.
*/
final class LoginConfirmedEvent extends AbstractConfirmedEvent {}
+35
View File
@@ -0,0 +1,35 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* A notification if something went wrong while trying to log in a user.
*/
final readonly class LoginErrorOccurredEvent
{
public function __construct(
private ServerRequestInterface $request
) {}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?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\Event;
/**
* A notification when a log out has successfully arrived at the plugin, via the view and the controller, multiple
* information can be overridden in Event Listeners.
*/
final class LogoutConfirmedEvent extends AbstractConfirmedEvent {}
@@ -0,0 +1,42 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Allows to inject custom variables into the login form.
*/
final readonly class ModifyLoginFormViewEvent
{
public function __construct(
private ViewInterface $view,
private ServerRequestInterface $request
) {}
public function getView(): ViewInterface
{
return $this->view;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,52 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Allows to modify the result of the redirect URL validation (e.g. allow redirect to specific external URLs).
*/
final class ModifyRedirectUrlValidationResultEvent
{
public function __construct(
private readonly string $redirectUrl,
private bool $validationResult,
private readonly ServerRequestInterface $request,
) {}
public function getRedirectUrl(): string
{
return $this->redirectUrl;
}
public function getValidationResult(): bool
{
return $this->validationResult;
}
public function setValidationResult(bool $validationResult): void
{
$this->validationResult = $validationResult;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?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\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Informal event that contains information about the password which was set, and is about to be stored in the database.
*/
final readonly class PasswordChangeEvent
{
public function __construct(
private array $user,
private string $passwordHash,
private string $rawPassword,
private ServerRequestInterface $request
) {}
public function getUser(): array
{
return $this->user;
}
public function getHashedPassword(): string
{
return $this->passwordHash;
}
public function getRawPassword(): string
{
return $this->rawPassword;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,43 @@
<?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\Event;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Authentication\Event\BeforeRequestTokenProcessedEvent;
use TYPO3\CMS\Core\Security\RequestToken;
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
/**
* Process request token.
*/
final class ProcessRequestTokenListener
{
#[AsEventListener('felogin-process-request-token')]
public function __invoke(BeforeRequestTokenProcessedEvent $event): void
{
$user = $event->getUser();
$requestToken = $event->getRequestToken();
if (!$user instanceof FrontendUserAuthentication || !$requestToken instanceof RequestToken) {
return;
}
$pidParam = (string)($requestToken->params['pid'] ?? '');
if ($user->checkPid) {
$user->checkPid_value = $pidParam;
}
}
}
+41
View File
@@ -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\FrontendLogin\Event;
use TYPO3\CMS\Core\Mail\FluidEmail;
/**
* Event that contains the email to be sent to the user when they request a new password.
* More
*
* Additional validation can happen here.
*/
final readonly class SendRecoveryEmailEvent
{
public function __construct(private FluidEmail $email, private array $user) {}
public function getUserInformation(): array
{
return $this->user;
}
public function getEmail(): FluidEmail
{
return $this->email;
}
}
+249
View File
@@ -0,0 +1,249 @@
<?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\Redirect;
use TYPO3\CMS\Core\Authentication\LoginType;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\FrontendLogin\Configuration\RedirectConfiguration;
use TYPO3\CMS\FrontendLogin\Validation\RedirectUrlValidator;
/**
* Resolve felogin related redirects based on the current login type and the selected configuration (redirect mode)
*
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
class RedirectHandler
{
protected bool $userIsLoggedIn = false;
public function __construct(
protected RedirectModeHandler $redirectModeHandler,
protected RedirectUrlValidator $redirectUrlValidator,
Context $context
) {
$this->userIsLoggedIn = (bool)$context->getPropertyFromAspect('frontend.user', 'isLoggedIn');
}
/**
* Process redirect modes. This method searches for a redirect url using all configured modes and returns it.
*/
public function processRedirect(RequestInterface $request, string $loginType, RedirectConfiguration $configuration, string $redirectModeReferrer): string
{
if ($this->isUserLoginFailedAndLoginErrorActive($configuration->getModes(), $loginType)) {
return $this->redirectModeHandler->redirectModeLoginError($request, $configuration->getPageOnLoginError());
}
$redirectUrlList = [];
foreach ($configuration->getModes() as $redirectMode) {
$redirectUrl = '';
$type = LoginType::tryFrom($loginType);
if ($type === LoginType::LOGIN) {
$redirectUrl = $this->handleSuccessfulLogin($request, $redirectMode, $configuration->getPageOnLogin(), $configuration->getDomains(), $redirectModeReferrer);
} elseif ($type === LoginType::LOGOUT) {
$redirectUrl = $this->handleSuccessfulLogout($request, $redirectMode, $configuration->getPageOnLogout());
}
if ($redirectUrl !== '') {
$redirectUrlList[] = $redirectUrl;
}
}
return $this->fetchReturnUrlFromList($redirectUrlList, $configuration->getFirstMode());
}
/**
* Get alternative logout form redirect url if logout and page not accessible
*/
protected function getLogoutRedirectUrl(RequestInterface $request, array $redirectModes, int $redirectPageLogout = 0): string
{
if ($this->userIsLoggedIn && $this->isRedirectModeActive($redirectModes, RedirectMode::LOGOUT)) {
return $this->redirectModeHandler->redirectModeLogout($request, $redirectPageLogout);
}
return $this->getGetpostRedirectUrl($request, $redirectModes);
}
/**
* Is used for alternative redirect urls on redirect mode "getpost"
*/
protected function getGetpostRedirectUrl(RequestInterface $request, array $redirectModes): string
{
return $this->isRedirectModeActive($redirectModes, RedirectMode::GETPOST)
? $this->getRedirectUrlRequestParam($request)
: '';
}
/**
* Handle redirect mode logout
*/
protected function handleSuccessfulLogout(RequestInterface $request, string $redirectMode, int $redirectPageLogout): string
{
if ($redirectMode === RedirectMode::LOGOUT) {
return $this->redirectModeHandler->redirectModeLogout($request, $redirectPageLogout);
}
return '';
}
/**
* Base on setting redirectFirstMethod get first or last entry from redirect url list.
*/
protected function fetchReturnUrlFromList(array $redirectUrlList, string $redirectFirstMethod): string
{
if (count($redirectUrlList) === 0) {
return '';
}
// Remove empty values, but keep "0" as value (that's why "strlen" is used as second parameter)
$redirectUrlList = array_filter($redirectUrlList, static function (string $value): bool {
return strlen($value) > 0;
});
return $redirectFirstMethod
? array_shift($redirectUrlList)
: array_pop($redirectUrlList);
}
/**
* Generate redirect_url for case that the user was successfully logged in
*/
protected function handleSuccessfulLogin(RequestInterface $request, string $redirectMode, int $redirectPageLogin = 0, string $domains = '', string $redirectModeReferrer = ''): string
{
if (!$this->userIsLoggedIn) {
return '';
}
// Logintype is needed because the login-page wouldn't be accessible anymore after a login (would always redirect)
switch ($redirectMode) {
case RedirectMode::GROUP_LOGIN:
$redirectUrl = $this->redirectModeHandler->redirectModeGroupLogin($request);
break;
case RedirectMode::USER_LOGIN:
$redirectUrl = $this->redirectModeHandler->redirectModeUserLogin($request);
break;
case RedirectMode::LOGIN:
$redirectUrl = $this->redirectModeHandler->redirectModeLogin($request, $redirectPageLogin);
break;
case RedirectMode::GETPOST:
$redirectUrl = $this->getRedirectUrlRequestParam($request);
break;
case RedirectMode::REFERRER:
$redirectUrl = $this->redirectModeHandler->redirectModeReferrer($request, $redirectModeReferrer);
break;
case RedirectMode::REFERRER_DOMAINS:
$redirectUrl = $this->redirectModeHandler->redirectModeReferrerDomains($request, $domains, $redirectModeReferrer);
break;
default:
$redirectUrl = '';
}
return $redirectUrl;
}
protected function isUserLoginFailedAndLoginErrorActive(array $redirectModes, string $loginType): bool
{
return LoginType::tryFrom($loginType) === LoginType::LOGIN
&& !$this->userIsLoggedIn
&& $this->isRedirectModeActive($redirectModes, RedirectMode::LOGIN_ERROR);
}
protected function isRedirectModeActive(array $redirectModes, string $mode): bool
{
return in_array($mode, $redirectModes, true);
}
/**
* Returns the redirect Url that should be used in login form template for GET/POST redirect mode
*/
public function getLoginFormRedirectUrl(
RequestInterface $request,
RedirectConfiguration $configuration,
bool $redirectDisabled
): string {
if (!$redirectDisabled) {
return $this->getGetpostRedirectUrl($request, $configuration->getModes());
}
return '';
}
/**
* Determines the `referer` variable used in the login form for loginMode=referer depending on the
* following evaluation order:
*
* - HTTP POST parameter `referer`
* - HTTP GET parameter `referer`
* - HTTP_REFERER
* - URL of initiating request in case plugin has been called via sub-request
*
* The evaluated `referer` is only returned, if it is considered valid.
*/
public function getReferrerForLoginForm(RequestInterface $request, array $settings): string
{
// Early return, if redirectMode is not configured to respect the referrer
if (!$this->isReferrerRedirectEnabled($settings)) {
return '';
}
// Early return, if redirectReferrer is not enabled in current context (e.g., after a password reset)
if (($request->getQueryParams()['tx_felogin_login']['redirectReferrer'] ?? '') === 'off') {
return '';
}
$referrer = (string)(
$request->getParsedBody()['referer']
?? $request->getQueryParams()['referer']
?? $request->getServerParams()['HTTP_REFERER']
?? ''
);
// If the current request was initiated via sub-request, we use the URI of the original request as referrer
if ($originalRequest = $request->getAttribute('originalRequest', false)) {
$referrer = (string)$originalRequest->getUri();
}
if ($this->redirectUrlValidator->isValid($request, $referrer)) {
return $referrer;
}
return '';
}
/**
* Returns whether redirect based on the referrer is enabled
*/
protected function isReferrerRedirectEnabled(array $settings): bool
{
$referrerRedirectModes = [RedirectMode::REFERRER, RedirectMode::REFERRER_DOMAINS];
$configuredRedirectModes = GeneralUtility::trimExplode(',', $settings['redirectMode'] ?? '');
return count(array_intersect($configuredRedirectModes, $referrerRedirectModes)) > 0;
}
/**
* Returns validated redirect url contained in request param return_url or redirect_url
*/
private function getRedirectUrlRequestParam(RequestInterface $request): string
{
// If config.typolinkLinkAccessRestrictedPages is set, the var is return_url
$returnUrlFromRequest = (string)($request->getParsedBody()['return_url'] ?? $request->getQueryParams()['return_url'] ?? null);
$redirectUrlFromRequest = (string)($request->getParsedBody()['redirect_url'] ?? $request->getQueryParams()['redirect_url'] ?? null);
$redirectUrl = $returnUrlFromRequest ?: $redirectUrlFromRequest;
return $this->redirectUrlValidator->isValid($request, $redirectUrl) ? $redirectUrl : '';
}
}
+35
View File
@@ -0,0 +1,35 @@
<?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\Redirect;
/**
* Contains the different redirect modes types
*
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
final class RedirectMode
{
public const string LOGIN = 'login';
public const string LOGOUT = 'logout';
public const string LOGIN_ERROR = 'loginError';
public const string GETPOST = 'getpost';
public const string USER_LOGIN = 'userLogin';
public const string GROUP_LOGIN = 'groupLogin';
public const string REFERRER = 'referer';
public const string REFERRER_DOMAINS = 'refererDomains';
}
+181
View File
@@ -0,0 +1,181 @@
<?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\Redirect;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder;
use TYPO3\CMS\FrontendLogin\Domain\Repository\FrontendUserGroupRepository;
use TYPO3\CMS\FrontendLogin\Domain\Repository\FrontendUserRepository;
use TYPO3\CMS\FrontendLogin\Validation\RedirectUrlValidator;
/**
* Do felogin related redirects
*
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
readonly class RedirectModeHandler
{
public function __construct(
protected UriBuilder $uriBuilder,
protected RedirectUrlValidator $redirectUrlValidator,
private FrontendUserRepository $frontendUserRepository,
private FrontendUserGroupRepository $frontendUserGroupRepository
) {}
/**
* Handle redirect mode groupLogin
*/
public function redirectModeGroupLogin(RequestInterface $request): string
{
$groups = $request->getAttribute('frontend.user')->userGroups;
if (empty($groups)) {
return '';
}
$groupUids = array_keys($groups);
// Take the first group with a redirect page
foreach ($groupUids as $groupUid) {
$redirectPageId = (int)$this->frontendUserGroupRepository
->findRedirectPageIdByGroupId($groupUid);
if ($redirectPageId > 0) {
return $this->buildUriForPageUid($request, $redirectPageId);
}
}
return '';
}
/**
* Handle redirect mode userLogin
*/
public function redirectModeUserLogin(RequestInterface $request): string
{
$userUid = (int)$request->getAttribute('frontend.user')->user['uid'];
$redirectPageId = $this->frontendUserRepository->findRedirectIdPageByUserId($userUid);
if ($redirectPageId === null) {
return '';
}
return $this->buildUriForPageUid($request, $redirectPageId);
}
/**
* Handle redirect mode login
*/
public function redirectModeLogin(RequestInterface $request, int $redirectPageLogin): string
{
$redirectUrl = '';
if ($redirectPageLogin !== 0) {
$redirectUrl = $this->buildUriForPageUid($request, $redirectPageLogin);
}
return $redirectUrl;
}
/**
* Handle redirect mode referrer
*/
public function redirectModeReferrer(RequestInterface $request, string $redirectReferrer): string
{
$redirectUrl = '';
if ($redirectReferrer !== 'off') {
// Avoid forced logout, when trying to login immediately after a logout
$redirectUrl = preg_replace('/[&?]logintype=[a-z]+/', '', $this->getReferrer($request));
}
return $redirectUrl ?? '';
}
/**
* Handle redirect mode refererDomains
*/
public function redirectModeReferrerDomains(RequestInterface $request, string $domains, string $redirectReferrer): string
{
$redirectUrl = '';
if ($redirectReferrer !== '') {
return '';
}
// Auto redirect.
// Feature to redirect to the page where the user came from (HTTP_REFERER).
// Allowed domains to redirect to, can be configured with plugin.tx_felogin_login.domains
// also avoid redirect when logging in after changing password
if ($domains) {
$url = $this->getReferrer($request);
// Is referring url allowed to redirect?
$match = [];
if (preg_match('#^https?://([[:alnum:].-]+)/#', $url, $match)) {
$redirectDomain = $match[1];
$found = false;
foreach (GeneralUtility::trimExplode(',', $domains, true) as $domain) {
if (preg_match('/(?:^|\\.)' . preg_quote($domain, '/') . '$/', $redirectDomain)) {
$found = true;
break;
}
}
if (!$found) {
$url = '';
}
}
// Avoid forced logout, when trying to login immediately after a logout
if ($url) {
$redirectUrl = preg_replace('/[&?]logintype=[a-z]+/', '', $url);
}
}
return $redirectUrl ?? '';
}
/**
* Handle redirect mode loginError after login-error
*/
public function redirectModeLoginError(RequestInterface $request, int $redirectPageLoginError = 0): string
{
$redirectUrl = '';
if ($redirectPageLoginError > 0) {
$redirectUrl = $this->buildUriForPageUid($request, $redirectPageLoginError);
}
return $redirectUrl;
}
/**
* Handle redirect mode logout
*/
public function redirectModeLogout(RequestInterface $request, int $redirectPageLogout): string
{
$redirectUrl = '';
if ($redirectPageLogout > 0) {
$redirectUrl = $this->buildUriForPageUid($request, $redirectPageLogout);
}
return $redirectUrl;
}
protected function buildUriForPageUid(RequestInterface $request, int $pageUid): string
{
$this->uriBuilder->reset();
$this->uriBuilder->setRequest($request);
$this->uriBuilder->setTargetPageUid($pageUid);
return $this->uriBuilder->build();
}
protected function getReferrer(RequestInterface $request): string
{
$referrer = '';
$requestReferrer = (string)($request->getParsedBody()['referer'] ?? $request->getQueryParams()['referer'] ?? '');
if ($this->redirectUrlValidator->isValid($request, $requestReferrer)) {
$referrer = $requestReferrer;
}
return $referrer;
}
}
+132
View File
@@ -0,0 +1,132 @@
<?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\Service;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Address;
use TYPO3\CMS\Core\Mail\FluidEmail;
use TYPO3\CMS\Core\Mail\MailerInterface;
use TYPO3\CMS\Core\Mail\TemplatedEmailFactory;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder;
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
use TYPO3\CMS\FrontendLogin\Configuration\RecoveryConfiguration;
use TYPO3\CMS\FrontendLogin\Event\SendRecoveryEmailEvent;
/**
* @internal this is a concrete TYPO3 implementation and solely used for EXT:felogin and not part of TYPO3's Core API.
*/
class RecoveryService
{
protected array $settings;
public function __construct(
protected readonly MailerInterface $mailer,
protected readonly TemplatedEmailFactory $templatedEmailFactory,
protected EventDispatcherInterface $eventDispatcher,
ConfigurationManagerInterface $configurationManager,
protected RecoveryConfiguration $recoveryConfiguration,
protected UriBuilder $uriBuilder
) {
$this->settings = $configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_SETTINGS);
}
/**
* Sends an email with an absolute link including the given forgot hash to the passed user
* with instructions to recover the account.
*
* @throws TransportExceptionInterface
*/
public function sendRecoveryEmail(RequestInterface $request, array $userData, string $hash): void
{
$receiver = new Address($userData['email'], $this->getReceiverName($userData));
$email = $this->prepareMail($request, $receiver, $hash, $userData);
$event = new SendRecoveryEmailEvent($email, $userData);
$this->eventDispatcher->dispatch($event);
$this->mailer->send($event->getEmail());
}
/**
* Get display name from values. Fallback to username if none of the "_name" fields is set.
*/
protected function getReceiverName(array $userInformation): string
{
$displayName = trim(
sprintf(
'%s%s%s',
$userInformation['first_name'],
$userInformation['middle_name'] ? " {$userInformation['middle_name']}" : '',
$userInformation['last_name'] ? " {$userInformation['last_name']}" : ''
)
);
return $displayName ? $displayName . ' (' . $userInformation['username'] . ')' : $userInformation['username'];
}
/**
* Create email object from configuration.
*/
protected function prepareMail(RequestInterface $request, Address $receiver, string $hash, array $userData): FluidEmail
{
$url = $this->uriBuilder
->reset()
->setRequest($request)
->setCreateAbsoluteUri(true)
->uriFor(
'showChangePassword',
['hash' => $hash],
'PasswordRecovery',
'felogin',
'Login'
);
$variables = [
'receiverName' => $receiver->getName(),
'userData' => $userData,
'url' => $url,
'validUntil' => date($this->settings['dateFormat'] ?? 'Y-m-d H:i', $this->recoveryConfiguration->getLifeTimeTimestamp()),
];
$mail = $this->templatedEmailFactory->createWithOverrides(
$this->settings['email']['templateRootPaths'] ?? [],
$this->settings['email']['layoutRootPaths'] ?? [],
$this->settings['email']['partialRootPaths'] ?? [],
$request,
);
$mail->subject($this->getEmailSubject())
->from($this->recoveryConfiguration->getSender())
->to($receiver)
->assignMultiple($variables)
->setTemplate($this->recoveryConfiguration->getMailTemplateName());
$replyTo = $this->recoveryConfiguration->getReplyTo();
if ($replyTo) {
$mail->addReplyTo($replyTo);
}
return $mail;
}
protected function getEmailSubject(): string
{
return LocalizationUtility::translate('password_recovery_mail_header', 'felogin');
}
}
+120
View File
@@ -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\FrontendLogin\Validation;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\FrontendLogin\Event\ModifyRedirectUrlValidationResultEvent;
/**
* Used to check if a referrer or a redirect URL is valid to be used as within Frontend Logins
* for redirects.
*
* @internal for now as it might get adopted for further streamlining against other validation paradigms
*/
readonly class RedirectUrlValidator
{
public function __construct(
protected SiteFinder $siteFinder,
protected EventDispatcherInterface $eventDispatcher,
protected LoggerInterface $logger,
) {}
/**
* Checks if a given URL is valid / properly sanitized and/or the domain is known to TYPO3.
*/
public function isValid(RequestInterface $request, string $value): bool
{
if ($value === '') {
return false;
}
// Validate the URL
$result = false;
if ($this->isRelativeUrl($request, $value) || $this->isInCurrentDomain($request, $value) || $this->isInLocalDomain($value)) {
$result = true;
}
// Allow to change the validation result via a PSR-14 event
$event = new ModifyRedirectUrlValidationResultEvent($value, $result, $request);
$event = $this->eventDispatcher->dispatch($event);
$result = $event->getValidationResult();
// URL is not allowed
if (!$result) {
$this->logger->debug('Url "{url}" was not accepted.', ['url' => $value]);
}
return $result;
}
/**
* Determines whether the URL is on the current host and belongs to the
* current TYPO3 installation. The scheme part is ignored in the comparison.
*/
protected function isInCurrentDomain(RequestInterface $request, string $url): bool
{
$urlWithoutSchema = preg_replace('#^https?://#', '', $url) ?? '';
$siteUrlWithoutSchema = preg_replace('#^https?://#', '', $request->getAttribute('normalizedParams')->getSiteUrl()) ?? '';
// this condition only exists to satisfy phpstan, which complains that this could be an array, too.
if (is_array($siteUrlWithoutSchema)) {
$siteUrlWithoutSchema = $siteUrlWithoutSchema[0];
}
return str_starts_with($urlWithoutSchema . '/', $request->getAttribute('normalizedParams')->getHttpHost() . '/')
&& str_starts_with($urlWithoutSchema, $siteUrlWithoutSchema);
}
/**
* Determines whether the URL matches a domain known to TYPO3.
*/
protected function isInLocalDomain(string $url): bool
{
if (!GeneralUtility::isValidUrl($url)) {
return false;
}
$parsedUrl = parse_url($url);
if ($parsedUrl['scheme'] === 'http' || $parsedUrl['scheme'] === 'https') {
$host = $parsedUrl['host'];
foreach ($this->siteFinder->getAllSites() as $site) {
if ($site->getBase()->getHost() === $host) {
return true;
}
}
}
return false;
}
/**
* Determines whether the URL is relative to the current TYPO3 installation.
*/
protected function isRelativeUrl(RequestInterface $request, string $url): bool
{
$url = GeneralUtility::sanitizeLocalUrl($url, $request);
if (!empty($url)) {
$parsedUrl = @parse_url($url);
if ($parsedUrl !== false && !isset($parsedUrl['scheme']) && !isset($parsedUrl['host'])) {
// If the relative URL starts with a slash, we need to check if it's within the current site path
return $parsedUrl['path'][0] !== '/' || str_starts_with($parsedUrl['path'], $request->getAttribute('normalizedParams')->getSitePath());
}
}
return false;
}
}