526 lines
22 KiB
PHP
526 lines
22 KiB
PHP
<?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\Backend\Authentication;
|
|
|
|
use Doctrine\DBAL\Platforms\MariaDBPlatform as DoctrineMariaDBPlatform;
|
|
use Doctrine\DBAL\Platforms\MySQLPlatform as DoctrineMySQLPlatform;
|
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use Psr\Http\Message\UriInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
|
use Symfony\Component\Mime\Address;
|
|
use TYPO3\CMS\Backend\Authentication\Event\PasswordHasBeenResetEvent;
|
|
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
|
use TYPO3\CMS\Core\Context\Context;
|
|
use TYPO3\CMS\Core\Crypto\HashAlgo;
|
|
use TYPO3\CMS\Core\Crypto\HashService;
|
|
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
|
|
use TYPO3\CMS\Core\Crypto\Random;
|
|
use TYPO3\CMS\Core\Database\Connection;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
|
|
use TYPO3\CMS\Core\Http\NormalizedParams;
|
|
use TYPO3\CMS\Core\Mail\MailerInterface;
|
|
use TYPO3\CMS\Core\Mail\TemplatedEmailFactory;
|
|
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\SysLog\Action\Login as SystemLogLoginAction;
|
|
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
|
|
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* This class is responsible for
|
|
* - find the right user, sending out a reset email.
|
|
* - create a token for creating the link (not exposed outside of this class)
|
|
* - validate a hashed token
|
|
* - send out an email to initiate the password reset
|
|
* - update a password for a backend user if all parameters match
|
|
*
|
|
* @internal this is a concrete implementation for User/Password login and not part of public TYPO3 Core API.
|
|
*/
|
|
#[Autoconfigure(public: true)]
|
|
readonly class PasswordReset
|
|
{
|
|
protected const TOKEN_VALID_UNTIL = '+2 hours';
|
|
|
|
public function __construct(
|
|
private LoggerInterface $logger,
|
|
private MailerInterface $mailer,
|
|
private TemplatedEmailFactory $templatedEmailFactory,
|
|
private HashService $hashService,
|
|
private Random $random,
|
|
private ConnectionPool $connectionPool,
|
|
private EventDispatcherInterface $eventDispatcher,
|
|
private PasswordHashFactory $passwordHashFactory,
|
|
private UriBuilder $uriBuilder,
|
|
private SessionManager $sessionManager,
|
|
private RateLimiterFactoryInterface $rateLimiterFactory,
|
|
) {}
|
|
|
|
/**
|
|
* Check if there are at least one in the system that contains a non-empty password AND an email address set.
|
|
*/
|
|
public function isEnabled(): bool
|
|
{
|
|
// Option not explicitly enabled
|
|
if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
|
|
return false;
|
|
}
|
|
$queryBuilder = $this->getPreparedQueryBuilder();
|
|
$statement = $queryBuilder
|
|
->select('uid')
|
|
->from('be_users')
|
|
->setMaxResults(1)
|
|
->executeQuery();
|
|
return (int)$statement->fetchOne() > 0;
|
|
}
|
|
|
|
/**
|
|
* Check if a specific backend user can be used to trigger an email reset for (email + password set)
|
|
*/
|
|
public function isEnabledForUser(int $userId): bool
|
|
{
|
|
$queryBuilder = $this->getPreparedQueryBuilder();
|
|
$statement = $queryBuilder
|
|
->select('uid')
|
|
->from('be_users')
|
|
->andWhere(
|
|
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
|
|
)
|
|
->setMaxResults(1)
|
|
->executeQuery();
|
|
return $statement->fetchOne() > 0;
|
|
}
|
|
|
|
/**
|
|
* Determine the right user and send out an email. If multiple users are found with the same email address
|
|
* an alternative email is sent.
|
|
*
|
|
* If no user is found, this is logged to the system (but not to sys_log).
|
|
*
|
|
* The method intentionally does not return anything to avoid any information disclosure or exposure.
|
|
*
|
|
* @param ServerRequestInterface $request
|
|
* @param Context $context
|
|
* @param string $emailAddress
|
|
*/
|
|
public function initiateReset(ServerRequestInterface $request, Context $context, string $emailAddress): void
|
|
{
|
|
if (!GeneralUtility::validEmail($emailAddress)) {
|
|
return;
|
|
}
|
|
if ($this->hasExceededMaximumAttemptsForReset($emailAddress)) {
|
|
$this->logger->alert('Password reset requested for email {email} but was requested too many times.', ['email' => $emailAddress]);
|
|
return;
|
|
}
|
|
$queryBuilder = $this->getPreparedQueryBuilder();
|
|
$users = $queryBuilder
|
|
->select('*')
|
|
->from('be_users')
|
|
->andWhere(
|
|
$queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($emailAddress))
|
|
)
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
if ($users === []) {
|
|
// No user found, do nothing, also no log to sys_log in order avoid log flooding
|
|
$this->logger->warning('Password reset requested for email {email} but no valid users', ['email' => $emailAddress]);
|
|
} elseif (count($users) > 1) {
|
|
// More than one user with the same email address found, send out the email that one cannot send out a reset link
|
|
$this->sendAmbiguousEmail($request, $context, $emailAddress);
|
|
} else {
|
|
$user = reset($users);
|
|
unset($user['password']);
|
|
$this->sendResetEmail($request, $context, $user);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send out an email to a given email address and note that a reset was triggered but email was used multiple times.
|
|
* Used when the database returned multiple users.
|
|
*/
|
|
protected function sendAmbiguousEmail(ServerRequestInterface $request, Context $context, string $emailAddress): void
|
|
{
|
|
$emailObject = $this->templatedEmailFactory->create($request)
|
|
->to(new Address($emailAddress))
|
|
->assign('email', $emailAddress)
|
|
->setTemplate('PasswordReset/AmbiguousResetRequested');
|
|
$this->mailer->send($emailObject);
|
|
$this->logger->warning('Password reset sent to email address {email} but multiple accounts found', ['email' => $emailAddress]);
|
|
$this->log(
|
|
'Sent password reset email to email address %s but with multiple accounts attached.',
|
|
SystemLogLoginAction::PASSWORD_RESET_REQUEST,
|
|
SystemLogErrorClassification::WARNING,
|
|
0,
|
|
[
|
|
'email' => $emailAddress,
|
|
],
|
|
NormalizedParams::createFromRequest($request)->getRemoteAddress(),
|
|
$context
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send out an email to a user that does have an email address added to his account, containing a reset link.
|
|
*/
|
|
protected function sendResetEmail(ServerRequestInterface $request, Context $context, array $user): void
|
|
{
|
|
$resetLink = $this->generateResetLinkForUser($context, (int)$user['uid'], (string)$user['email']);
|
|
$emailObject = $this->templatedEmailFactory->create($request)
|
|
->to(new Address((string)$user['email'], $user['realName']))
|
|
->assign('name', $user['realName'])
|
|
->assign('email', $user['email'])
|
|
->assign('language', $user['lang'] ?: 'en')
|
|
->assign('resetLink', $resetLink)
|
|
->assign('username', $user['username'])
|
|
->assign('userData', $user)
|
|
->setTemplate('PasswordReset/ResetRequested');
|
|
|
|
$this->mailer->send($emailObject);
|
|
|
|
$this->logger->info('Sent password reset email to email address {email} for user {username}', [
|
|
'email' => $user['email'],
|
|
'username' => $user['username'],
|
|
]);
|
|
$this->log(
|
|
'Sent password reset email to email address %s',
|
|
SystemLogLoginAction::PASSWORD_RESET_REQUEST,
|
|
SystemLogErrorClassification::SECURITY_NOTICE,
|
|
(int)$user['uid'],
|
|
[
|
|
'email' => $user['email'],
|
|
],
|
|
NormalizedParams::createFromRequest($request)->getRemoteAddress(),
|
|
$context
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a token, stores it in the database, and then creates an absolute URL for resetting the password.
|
|
* This is all in one method so it is not exposed from the outside.
|
|
*
|
|
* This function requires:
|
|
* a) the user is allowed to do a password reset (no check is done anymore)
|
|
* b) a valid email address.
|
|
*
|
|
* @param Context $context
|
|
* @param int $userId the backend user uid
|
|
* @param string $emailAddress is part of the hash to ensure that the email address does not get reset.
|
|
*/
|
|
protected function generateResetLinkForUser(Context $context, int $userId, string $emailAddress): UriInterface
|
|
{
|
|
$token = $this->random->generateRandomHexString(96);
|
|
$currentTime = $context->getAspect('date')->getDateTime();
|
|
$expiresOn = $currentTime->modify(self::TOKEN_VALID_UNTIL);
|
|
// Create a hash ("one time password") out of the token including the timestamp of the expiration date
|
|
$hash = $this->hashService->hmac($token . '|' . $expiresOn->getTimestamp() . '|' . $emailAddress . '|' . $userId, 'password-reset', HashAlgo::SHA3_256);
|
|
|
|
// Set the token in the database, which is hashed
|
|
$this->connectionPool
|
|
->getConnectionForTable('be_users')
|
|
->update(
|
|
'be_users',
|
|
['password_reset_token' => $this->passwordHashFactory->getDefaultHashInstance('BE')->getHashedPassword($hash)],
|
|
['uid' => $userId]
|
|
);
|
|
|
|
return $this->uriBuilder->buildUriFromRoute(
|
|
'password_reset_validate',
|
|
[
|
|
// "token"
|
|
't' => $token,
|
|
// "expiration date"
|
|
'e' => $expiresOn->getTimestamp(),
|
|
// "identity"
|
|
'i' => hash('sha1', $emailAddress . (string)$userId),
|
|
],
|
|
UriBuilder::ABSOLUTE_URL
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates all query parameters / GET parameters of the given request against the token.
|
|
*/
|
|
public function isValidResetTokenFromRequest(ServerRequestInterface $request): bool
|
|
{
|
|
$user = $this->findValidUserForToken(
|
|
(string)($request->getQueryParams()['t'] ?? ''),
|
|
(string)($request->getQueryParams()['i'] ?? ''),
|
|
(int)($request->getQueryParams()['e'] ?? 0)
|
|
);
|
|
return $user !== null;
|
|
}
|
|
|
|
/**
|
|
* Fetch the user record from the database if the token is valid, and has matched all criteria
|
|
*
|
|
* @return array|null the BE User database record
|
|
*/
|
|
protected function findValidUserForToken(string $token, string $identity, int $expirationTimestamp): ?array
|
|
{
|
|
// Early return if token expired
|
|
if ($expirationTimestamp < time()) {
|
|
return null;
|
|
}
|
|
|
|
$user = null;
|
|
// Find the token in the database
|
|
$queryBuilder = $this->getPreparedQueryBuilder();
|
|
|
|
$queryBuilder
|
|
->select('uid', 'username', 'realName', 'email', 'password_reset_token', 'password')
|
|
->from('be_users');
|
|
|
|
$platform = $queryBuilder->getConnection()->getDatabasePlatform();
|
|
if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) {
|
|
$queryBuilder->andWhere(
|
|
$queryBuilder->expr()->comparison('SHA1(CONCAT(' . $queryBuilder->quoteIdentifier('email') . ', ' . $queryBuilder->quoteIdentifier('uid') . '))', $queryBuilder->expr()::EQ, $queryBuilder->createNamedParameter($identity))
|
|
);
|
|
$user = $queryBuilder->executeQuery()->fetchAssociative();
|
|
} else {
|
|
// no native SHA1/ CONCAT functionality, has to be done in PHP
|
|
$stmt = $queryBuilder->executeQuery();
|
|
while ($row = $stmt->fetchAssociative()) {
|
|
if (hash_equals(hash('sha1', $row['email'] . (string)$row['uid']), $identity)) {
|
|
$user = $row;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!is_array($user) || empty($user)) {
|
|
return null;
|
|
}
|
|
|
|
// Validate hash by rebuilding the hash from the parameters and the URL and see if this matches against the stored password_reset_token
|
|
$hash = $this->hashService->hmac($token . '|' . $expirationTimestamp . '|' . $user['email'] . '|' . $user['uid'], 'password-reset', HashAlgo::SHA3_256);
|
|
if (!$this->passwordHashFactory->getDefaultHashInstance('BE')->checkPassword($hash, $user['password_reset_token'] ?? '')) {
|
|
return null;
|
|
}
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* Update the password in the database if the password matches and the token is valid.
|
|
*
|
|
* @return bool whether the password was reset or not
|
|
*/
|
|
public function resetPassword(ServerRequestInterface $request, Context $context): bool
|
|
{
|
|
$expirationTimestamp = (int)($request->getQueryParams()['e'] ?? '');
|
|
$identityHash = (string)($request->getQueryParams()['i'] ?? '');
|
|
$token = (string)($request->getQueryParams()['t'] ?? '');
|
|
$newPassword = (string)($request->getParsedBody()['password'] ?? '');
|
|
$newPasswordRepeat = (string)($request->getParsedBody()['passwordrepeat'] ?? '');
|
|
|
|
$user = $this->findValidUserForToken($token, $identityHash, $expirationTimestamp);
|
|
if ($user === null) {
|
|
$this->logger->warning('Password reset not possible. Valid user for token not found.');
|
|
return false;
|
|
}
|
|
$userId = (int)$user['uid'];
|
|
|
|
if ($newPassword === '') {
|
|
$this->logger->debug('Password reset not possible because an empty password was provided.');
|
|
return false;
|
|
}
|
|
|
|
if ($newPassword !== $newPasswordRepeat) {
|
|
$this->logger->debug('Password reset not possible because new password and new password repeat do not match.');
|
|
return false;
|
|
}
|
|
|
|
if (!$this->isValidPassword($newPassword, $user)) {
|
|
$this->logger->debug('The new password does not match all requirements of the password policy.');
|
|
return false;
|
|
}
|
|
|
|
$this->connectionPool
|
|
->getConnectionForTable('be_users')
|
|
->update(
|
|
'be_users',
|
|
[
|
|
'password_reset_token' => '',
|
|
'password' => $this->passwordHashFactory->getDefaultHashInstance('BE')->getHashedPassword($newPassword),
|
|
],
|
|
['uid' => $userId]
|
|
);
|
|
|
|
$this->eventDispatcher->dispatch(new PasswordHasBeenResetEvent($userId));
|
|
|
|
$this->invalidateUserSessions($userId);
|
|
|
|
$this->logger->info('Password reset successful for user \'{username}\'', ['username' => $user['username'], 'user_id' => $userId]);
|
|
$this->log(
|
|
'Password reset successful for user %s',
|
|
SystemLogLoginAction::PASSWORD_RESET_ACCOMPLISHED,
|
|
SystemLogErrorClassification::SECURITY_NOTICE,
|
|
$userId,
|
|
[
|
|
'email' => $user['email'],
|
|
'user' => $userId,
|
|
],
|
|
NormalizedParams::createFromRequest($request)->getRemoteAddress(),
|
|
$context
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* The querybuilder for finding the right user - and adds some restrictions:
|
|
* - No CLI users
|
|
* - No Admin users (with option)
|
|
* - No hidden/deleted users
|
|
* - Password must be set
|
|
* - Username must be set
|
|
* - Email address must be set
|
|
*/
|
|
protected function getPreparedQueryBuilder(): QueryBuilder
|
|
{
|
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_users');
|
|
$queryBuilder->getRestrictions()
|
|
->removeAll()
|
|
->add(GeneralUtility::makeInstance(RootLevelRestriction::class))
|
|
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
|
|
->add(GeneralUtility::makeInstance(StartTimeRestriction::class))
|
|
->add(GeneralUtility::makeInstance(EndTimeRestriction::class))
|
|
->add(GeneralUtility::makeInstance(HiddenRestriction::class));
|
|
$queryBuilder->where(
|
|
$queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('')),
|
|
$queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('_cli_')),
|
|
$queryBuilder->expr()->neq('password', $queryBuilder->createNamedParameter('')),
|
|
$queryBuilder->expr()->neq('email', $queryBuilder->createNamedParameter(''))
|
|
);
|
|
if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] ?? false)) {
|
|
$queryBuilder->andWhere(
|
|
$queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
|
|
);
|
|
}
|
|
return $queryBuilder;
|
|
}
|
|
|
|
/**
|
|
* Adds an entry to "sys_log", also used to track the maximum allowed attempts.
|
|
*
|
|
* @param string $message the information / message in english
|
|
* @param int $action see SystemLogLoginAction
|
|
* @param int $error see SystemLogErrorClassification
|
|
* @param array $data additional information, used for the message
|
|
* @param string $ipAddress
|
|
*/
|
|
protected function log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, Context $context): void
|
|
{
|
|
$this->connectionPool
|
|
->getConnectionForTable('sys_log')
|
|
->insert(
|
|
'sys_log',
|
|
[
|
|
'userid' => $userId,
|
|
'type' => SystemLogType::LOGIN,
|
|
'channel' => SystemLogType::toChannel(SystemLogType::LOGIN),
|
|
'level' => SystemLogType::toLevel(SystemLogType::LOGIN),
|
|
'action' => $action,
|
|
'error' => $error,
|
|
'details' => $message,
|
|
'log_data' => json_encode($data),
|
|
'tablename' => 'be_users',
|
|
'recuid' => $userId,
|
|
'IP' => (string)$ipAddress,
|
|
'tstamp' => $context->getAspect('date')->get('timestamp'),
|
|
'event_pid' => 0,
|
|
'workspace' => 0,
|
|
],
|
|
[
|
|
Connection::PARAM_INT,
|
|
Connection::PARAM_INT,
|
|
Connection::PARAM_STR,
|
|
Connection::PARAM_STR,
|
|
Connection::PARAM_INT,
|
|
Connection::PARAM_INT,
|
|
Connection::PARAM_STR,
|
|
Connection::PARAM_STR,
|
|
Connection::PARAM_STR,
|
|
Connection::PARAM_INT,
|
|
Connection::PARAM_STR,
|
|
Connection::PARAM_INT,
|
|
Connection::PARAM_INT,
|
|
Connection::PARAM_INT,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks if an email reset link has been requested more than the configured amount of times.
|
|
* Default values are 3 times in the last 30 minutes configured in Services.yaml
|
|
*/
|
|
protected function hasExceededMaximumAttemptsForReset(string $email): bool
|
|
{
|
|
$limiter = $this->rateLimiterFactory->create($email);
|
|
$limit = $limiter->consume();
|
|
return !$limit->isAccepted();
|
|
}
|
|
|
|
/**
|
|
* Returns, if the given password is compliant with the global password policy for backend users
|
|
*/
|
|
protected function isValidPassword(string $password, array $user): bool
|
|
{
|
|
$passwordPolicy = $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? 'default';
|
|
$passwordPolicyValidator = GeneralUtility::makeInstance(
|
|
PasswordPolicyValidator::class,
|
|
PasswordPolicyAction::UPDATE_USER_PASSWORD,
|
|
is_string($passwordPolicy) ? $passwordPolicy : ''
|
|
);
|
|
$contextData = new ContextData(currentPasswordHash: $user['password']);
|
|
$contextData->setData('currentUsername', $user['username']);
|
|
$contextData->setData('currentFullname', $user['realName']);
|
|
$event = $this->eventDispatcher->dispatch(
|
|
new EnrichPasswordValidationContextDataEvent(
|
|
$contextData,
|
|
$user,
|
|
self::class
|
|
)
|
|
);
|
|
$contextData = $event->getContextData();
|
|
|
|
return $passwordPolicyValidator->isValidPassword($password, $contextData);
|
|
}
|
|
|
|
/**
|
|
* Invalidate all backend user sessions by given user id
|
|
*/
|
|
protected function invalidateUserSessions(int $userId): void
|
|
{
|
|
$this->sessionManager->invalidateAllSessionsByUserId(
|
|
$this->sessionManager->getSessionBackend('BE'),
|
|
$userId
|
|
);
|
|
}
|
|
}
|