Files

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
);
}
}