| Current Path : /var/www/surf/TYPO3/vendor/typo3/cms-backend/Classes/Authentication/ |
| Current File : /var/www/surf/TYPO3/vendor/typo3/cms-backend/Classes/Authentication/PasswordReset.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\MySQLPlatform;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\Mime\Address;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface;
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\FluidEmail;
use TYPO3\CMS\Core\Mail\MailerInterface;
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\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.
*/
class PasswordReset implements LoggerAwareInterface
{
use LoggerAwareTrait;
protected const TOKEN_VALID_UNTIL = '+2 hours';
protected const MAXIMUM_RESET_ATTEMPTS = 3;
protected const MAXIMUM_RESET_ATTEMPTS_SINCE = '-30 minutes';
/**
* 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($context, $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('uid', 'email', 'username', 'realName', 'lang')
->from('be_users')
->andWhere(
$queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($emailAddress))
)
->executeQuery()
->fetchAllAssociative();
if (!is_array($users) || count($users) === 0) {
// No user found, do nothing, also no log to sys_log in order avoid log flooding
$this->logger->warning('Password reset requested for email but no valid users');
} 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);
$this->sendResetEmail($request, $context, (array)$user, $emailAddress);
}
}
/**
* 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 = GeneralUtility::makeInstance(FluidEmail::class);
$emailObject
->to(new Address($emailAddress))
->setRequest($request)
->assign('email', $emailAddress)
->setTemplate('PasswordReset/AmbiguousResetRequested');
// TODO: DI should be used to inject the MailerInterface
GeneralUtility::makeInstance(MailerInterface::class)->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, string $emailAddress): void
{
$resetLink = $this->generateResetLinkForUser($context, (int)$user['uid'], (string)$user['email']);
$emailObject = GeneralUtility::makeInstance(FluidEmail::class);
$emailObject
->to(new Address((string)$user['email'], $user['realName']))
->setRequest($request)
->assign('name', $user['realName'])
->assign('email', $user['email'])
->assign('language', $user['lang'] ?: 'default')
->assign('resetLink', $resetLink)
->assign('username', $user['username'])
->setTemplate('PasswordReset/ResetRequested');
// TODO: DI should be used to inject the MailerInterface
GeneralUtility::makeInstance(MailerInterface::class)->send($emailObject);
$this->logger->info('Sent password reset email to email address {email} for user {username}', [
'email' => $emailAddress,
'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 = GeneralUtility::makeInstance(Random::class)->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 = GeneralUtility::hmac($token . '|' . (string)$expiresOn->getTimestamp() . '|' . $emailAddress . '|' . (string)$userId, 'password-reset');
// Set the token in the database, which is hashed
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('be_users')
->update('be_users', ['password_reset_token' => $this->getHasher()->getHashedPassword($hash)], ['uid' => $userId]);
return GeneralUtility::makeInstance(UriBuilder::class)->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');
if ($queryBuilder->getConnection()->getDatabasePlatform() instanceof MySQLPlatform) {
$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 = GeneralUtility::hmac($token . '|' . (string)$expirationTimestamp . '|' . $user['email'] . '|' . (string)$user['uid'], 'password-reset');
if (!$this->getHasher()->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.
*
* @param Context $context current context
* @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;
}
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('be_users')
->update('be_users', ['password_reset_token' => '', 'password' => $this->getHasher()->getHashedPassword($newPassword)], ['uid' => $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 = GeneralUtility::makeInstance(ConnectionPool::class)->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;
}
protected function getHasher(): PasswordHashInterface
{
return GeneralUtility::makeInstance(PasswordHashFactory::class)->getDefaultHashInstance('BE');
}
/**
* 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
{
$fields = [
'userid' => $userId,
'type' => SystemLogType::LOGIN,
'channel' => SystemLogType::toChannel(SystemLogType::LOGIN),
'level' => SystemLogType::toLevel(SystemLogType::LOGIN),
'action' => $action,
'error' => $error,
'details_nr' => 1,
'details' => $message,
'log_data' => json_encode($data),
'tablename' => 'be_users',
'recuid' => $userId,
'IP' => (string)$ipAddress,
'tstamp' => $context->getAspect('date')->get('timestamp'),
'event_pid' => 0,
'NEWid' => '',
'workspace' => 0,
];
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_log');
$connection->insert(
'sys_log',
$fields,
[
Connection::PARAM_INT,
Connection::PARAM_INT,
Connection::PARAM_STR,
Connection::PARAM_STR,
Connection::PARAM_INT,
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_STR,
Connection::PARAM_STR,
]
);
}
/**
* Checks if an email reset link has been requested more than 3 times in the last 30mins.
* If a password was successfully reset more than three times in 30 minutes, it would still fail.
*/
protected function hasExceededMaximumAttemptsForReset(Context $context, string $email): bool
{
$now = $context->getAspect('date')->getDateTime();
$numberOfAttempts = $this->getNumberOfInitiatedResetsForEmail($now->modify(self::MAXIMUM_RESET_ATTEMPTS_SINCE), $email);
return $numberOfAttempts > self::MAXIMUM_RESET_ATTEMPTS;
}
/**
* SQL query to find the amount of initiated resets from a given time.
*/
protected function getNumberOfInitiatedResetsForEmail(\DateTimeInterface $since, string $email): int
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log');
return (int)$queryBuilder
->count('uid')
->from('sys_log')
->where(
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(SystemLogType::LOGIN)),
$queryBuilder->expr()->eq('action', $queryBuilder->createNamedParameter(SystemLogLoginAction::PASSWORD_RESET_REQUEST)),
$queryBuilder->expr()->eq('log_data', $queryBuilder->createNamedParameter(json_encode(['email' => $email]))),
$queryBuilder->expr()->gte('tstamp', $queryBuilder->createNamedParameter($since->getTimestamp(), Connection::PARAM_INT))
)
->executeQuery()
->fetchOne();
}
/**
* 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 = GeneralUtility::makeInstance(EventDispatcherInterface::class)->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
{
$sessionManager = GeneralUtility::makeInstance(SessionManager::class);
$sessionBackend = $sessionManager->getSessionBackend('BE');
$sessionManager->invalidateAllSessionsByUserId($sessionBackend, $userId);
}
}