‪TYPO3CMS  11.5
PasswordReset.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
19 
20 use Doctrine\DBAL\Platforms\MySqlPlatform;
21 use Psr\Http\Message\ServerRequestInterface;
22 use Psr\Http\Message\UriInterface;
23 use Psr\Log\LoggerAwareInterface;
24 use Psr\Log\LoggerAwareTrait;
25 use Symfony\Component\Mime\Address;
33 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
43 use ‪TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
44 use ‪TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
45 use ‪TYPO3\CMS\Core\SysLog\Type as SystemLogType;
47 
58 class ‪PasswordReset implements LoggerAwareInterface
59 {
60  use LoggerAwareTrait;
61 
62  protected const ‪TOKEN_VALID_UNTIL = '+2 hours';
63  protected const ‪MAXIMUM_RESET_ATTEMPTS = 3;
64  protected const ‪MAXIMUM_RESET_ATTEMPTS_SINCE = '-30 minutes';
65 
69  public function ‪isEnabled(): bool
70  {
71  // Option not explicitly enabled
72  if (!(‪$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
73  return false;
74  }
75  $queryBuilder = $this->‪getPreparedQueryBuilder();
76  $statement = $queryBuilder
77  ->select('uid')
78  ->from('be_users')
79  ->setMaxResults(1)
80  ->executeQuery();
81  return (int)$statement->fetchOne() > 0;
82  }
83 
90  public function ‪isEnabledForUser(int $userId): bool
91  {
92  $queryBuilder = $this->‪getPreparedQueryBuilder();
93  $statement = $queryBuilder
94  ->select('uid')
95  ->from('be_users')
96  ->andWhere(
97  $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($userId, ‪Connection::PARAM_INT))
98  )
99  ->setMaxResults(1)
100  ->executeQuery();
101  return $statement->fetchOne() > 0;
102  }
103 
116  public function ‪initiateReset(ServerRequestInterface $request, ‪Context $context, string $emailAddress): void
117  {
118  if (!GeneralUtility::validEmail($emailAddress)) {
119  return;
120  }
121  if ($this->‪hasExceededMaximumAttemptsForReset($context, $emailAddress)) {
122  $this->logger->alert('Password reset requested for email {email} but was requested too many times.', ['email' => $emailAddress]);
123  return;
124  }
125  $queryBuilder = $this->‪getPreparedQueryBuilder();
126  $users = $queryBuilder
127  ->select('uid', 'email', 'username', 'realName', 'lang')
128  ->from('be_users')
129  ->andWhere(
130  $queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($emailAddress))
131  )
132  ->executeQuery()
133  ->fetchAllAssociative();
134  if (!is_array($users) || count($users) === 0) {
135  // No user found, do nothing, also no log to sys_log in order avoid log flooding
136  $this->logger->warning('Password reset requested for email but no valid users');
137  } elseif (count($users) > 1) {
138  // More than one user with the same email address found, send out the email that one cannot send out a reset link
139  $this->‪sendAmbiguousEmail($request, $context, $emailAddress);
140  } else {
141  $user = reset($users);
142  $this->‪sendResetEmail($request, $context, (array)$user, $emailAddress);
143  }
144  }
145 
154  protected function ‪sendAmbiguousEmail(ServerRequestInterface $request, ‪Context $context, string $emailAddress): void
155  {
156  $emailObject = GeneralUtility::makeInstance(FluidEmail::class);
157  $emailObject
158  ->to(new Address($emailAddress))
159  ->setRequest($request)
160  ->assign('email', $emailAddress)
161  ->setTemplate('PasswordReset/AmbiguousResetRequested');
162 
163  GeneralUtility::makeInstance(Mailer::class)->send($emailObject);
164  $this->logger->warning('Password reset sent to email address {email} but multiple accounts found', ['email' => $emailAddress]);
165  $this->‪log(
166  'Sent password reset email to email address %s but with multiple accounts attached.',
167  SystemLogLoginAction::PASSWORD_RESET_REQUEST,
168  SystemLogErrorClassification::WARNING,
169  0,
170  [
171  'email' => $emailAddress,
172  ],
173  ‪NormalizedParams::createFromRequest($request)->getRemoteAddress(),
174  $context
175  );
176  }
177 
186  protected function ‪sendResetEmail(ServerRequestInterface $request, ‪Context $context, array $user, string $emailAddress): void
187  {
188  $resetLink = $this->‪generateResetLinkForUser($context, (int)$user['uid'], (string)$user['email']);
189  $emailObject = GeneralUtility::makeInstance(FluidEmail::class);
190  $emailObject
191  ->to(new Address((string)$user['email'], $user['realName']))
192  ->setRequest($request)
193  ->assign('name', $user['realName'])
194  ->assign('email', $user['email'])
195  ->assign('language', $user['lang'] ?: 'default')
196  ->assign('resetLink', $resetLink)
197  ->setTemplate('PasswordReset/ResetRequested');
198 
199  GeneralUtility::makeInstance(Mailer::class)->send($emailObject);
200  $this->logger->info('Sent password reset email to email address {email} for user {username}', [
201  'email' => $emailAddress,
202  'username' => $user['username'],
203  ]);
204  $this->‪log(
205  'Sent password reset email to email address %s',
206  SystemLogLoginAction::PASSWORD_RESET_REQUEST,
207  SystemLogErrorClassification::SECURITY_NOTICE,
208  (int)$user['uid'],
209  [
210  'email' => $user['email'],
211  ],
212  ‪NormalizedParams::createFromRequest($request)->getRemoteAddress(),
213  $context
214  );
215  }
216 
230  protected function ‪generateResetLinkForUser(‪Context $context, int $userId, string $emailAddress): UriInterface
231  {
232  $token = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96);
233  $currentTime = $context->‪getAspect('date')->getDateTime();
234  $expiresOn = $currentTime->modify(self::TOKEN_VALID_UNTIL);
235  // Create a hash ("one time password") out of the token including the timestamp of the expiration date
236  $hash = GeneralUtility::hmac($token . '|' . (string)$expiresOn->getTimestamp() . '|' . $emailAddress . '|' . (string)$userId, 'password-reset');
237 
238  // Set the token in the database, which is hashed
239  GeneralUtility::makeInstance(ConnectionPool::class)
240  ->getConnectionForTable('be_users')
241  ->update('be_users', ['password_reset_token' => $this->‪getHasher()->getHashedPassword($hash)], ['uid' => $userId]);
242 
243  return GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute(
244  'password_reset_validate',
245  [
246  // "token"
247  't' => $token,
248  // "expiration date"
249  'e' => $expiresOn->getTimestamp(),
250  // "identity"
251  'i' => hash('sha1', $emailAddress . (string)$userId),
252  ],
254  );
255  }
256 
263  public function ‪isValidResetTokenFromRequest(ServerRequestInterface $request): bool
264  {
265  $user = $this->‪findValidUserForToken(
266  (string)($request->getQueryParams()['t'] ?? ''),
267  (string)($request->getQueryParams()['i'] ?? ''),
268  (int)($request->getQueryParams()['e'] ?? 0)
269  );
270  return $user !== null;
271  }
272 
281  protected function ‪findValidUserForToken(string $token, string $identity, int $expirationTimestamp): ?array
282  {
283  // Early return if token expired
284  if ($expirationTimestamp < time()) {
285  return null;
286  }
287 
288  $user = null;
289  // Find the token in the database
290  $queryBuilder = $this->‪getPreparedQueryBuilder();
291 
292  $queryBuilder
293  ->select('uid', 'email', 'password_reset_token')
294  ->from('be_users');
295  if ($queryBuilder->getConnection()->getDatabasePlatform() instanceof MySqlPlatform) {
296  $queryBuilder->andWhere(
297  $queryBuilder->expr()->comparison('SHA1(CONCAT(' . $queryBuilder->quoteIdentifier('email') . ', ' . $queryBuilder->quoteIdentifier('uid') . '))', $queryBuilder->expr()::EQ, $queryBuilder->createNamedParameter($identity))
298  );
299  $user = $queryBuilder->executeQuery()->fetchAssociative();
300  } else {
301  // no native SHA1/ CONCAT functionality, has to be done in PHP
302  $stmt = $queryBuilder->executeQuery();
303  while ($row = $stmt->fetchAssociative()) {
304  if (hash_equals(hash('sha1', $row['email'] . (string)$row['uid']), $identity)) {
305  $user = $row;
306  break;
307  }
308  }
309  }
310 
311  if (!is_array($user) || empty($user)) {
312  return null;
313  }
314 
315  // Validate hash by rebuilding the hash from the parameters and the URL and see if this matches against the stored password_reset_token
316  $hash = GeneralUtility::hmac($token . '|' . (string)$expirationTimestamp . '|' . $user['email'] . '|' . (string)$user['uid'], 'password-reset');
317  if (!$this->‪getHasher()->checkPassword($hash, $user['password_reset_token'] ?? '')) {
318  return null;
319  }
320  return $user;
321  }
322 
330  public function ‪resetPassword(ServerRequestInterface $request, ‪Context $context): bool
331  {
332  $expirationTimestamp = (int)($request->getQueryParams()['e'] ?? '');
333  $identityHash = (string)($request->getQueryParams()['i'] ?? '');
334  $token = (string)($request->getQueryParams()['t'] ?? '');
335  $newPassword = (string)($request->getParsedBody()['password'] ?? '');
336  $newPasswordRepeat = (string)($request->getParsedBody()['passwordrepeat'] ?? '');
337  if (strlen($newPassword) < 8 || $newPassword !== $newPasswordRepeat) {
338  $this->logger->debug('Password reset not possible due to weak password');
339  return false;
340  }
341  $user = $this->‪findValidUserForToken($token, $identityHash, $expirationTimestamp);
342  if ($user === null) {
343  $this->logger->warning('Password reset not possible. Valid user for token not found.');
344  return false;
345  }
346  $userId = (int)$user['uid'];
347 
348  GeneralUtility::makeInstance(ConnectionPool::class)
349  ->getConnectionForTable('be_users')
350  ->update('be_users', ['password_reset_token' => '', 'password' => $this->‪getHasher()->getHashedPassword($newPassword)], ['uid' => $userId]);
351 
352  $this->‪invalidateUserSessions($userId);
353 
354  $this->logger->info('Password reset successful for user {user_id)', ['user_id' => $userId]);
355  $this->‪log(
356  'Password reset successful for user %s',
357  SystemLogLoginAction::PASSWORD_RESET_ACCOMPLISHED,
358  SystemLogErrorClassification::SECURITY_NOTICE,
359  $userId,
360  [
361  'email' => $user['email'],
362  'user' => $userId,
363  ],
364  ‪NormalizedParams::createFromRequest($request)->getRemoteAddress(),
365  $context
366  );
367  return true;
368  }
369 
381  protected function ‪getPreparedQueryBuilder(): QueryBuilder
382  {
383  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
384  $queryBuilder->getRestrictions()
385  ->removeAll()
386  ->add(GeneralUtility::makeInstance(RootLevelRestriction::class))
387  ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
388  ->add(GeneralUtility::makeInstance(StartTimeRestriction::class))
389  ->add(GeneralUtility::makeInstance(EndTimeRestriction::class))
390  ->add(GeneralUtility::makeInstance(HiddenRestriction::class));
391  $queryBuilder->where(
392  $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('')),
393  $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('_cli_')),
394  $queryBuilder->expr()->neq('password', $queryBuilder->createNamedParameter('')),
395  $queryBuilder->expr()->neq('email', $queryBuilder->createNamedParameter(''))
396  );
397  if (!(‪$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] ?? false)) {
398  $queryBuilder->andWhere(
399  $queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(0, ‪Connection::PARAM_INT))
400  );
401  }
402  return $queryBuilder;
403  }
404 
406  {
407  return GeneralUtility::makeInstance(PasswordHashFactory::class)->getDefaultHashInstance('BE');
408  }
409 
421  protected function ‪log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, ‪Context $context): void
422  {
423  ‪$fields = [
424  'userid' => $userId,
425  'type' => SystemLogType::LOGIN,
426  'channel' => SystemLogType::toChannel(SystemLogType::LOGIN),
427  'level' => SystemLogType::toLevel(SystemLogType::LOGIN),
428  'action' => $action,
429  'error' => $error,
430  'details_nr' => 1,
431  'details' => $message,
432  'log_data' => serialize($data),
433  'tablename' => 'be_users',
434  'recuid' => $userId,
435  'IP' => (string)$ipAddress,
436  'tstamp' => $context->‪getAspect('date')->‪get('timestamp'),
437  'event_pid' => 0,
438  'NEWid' => '',
439  'workspace' => 0,
440  ];
441 
442  $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_log');
443  $connection->insert(
444  'sys_log',
445  ‪$fields,
446  [
463  ]
464  );
465  }
466 
475  protected function ‪hasExceededMaximumAttemptsForReset(‪Context $context, string $email): bool
476  {
477  $now = $context->‪getAspect('date')->getDateTime();
478  $numberOfAttempts = $this->‪getNumberOfInitiatedResetsForEmail($now->modify(self::MAXIMUM_RESET_ATTEMPTS_SINCE), $email);
479  return $numberOfAttempts > ‪self::MAXIMUM_RESET_ATTEMPTS;
480  }
481 
489  protected function ‪getNumberOfInitiatedResetsForEmail(\DateTimeInterface $since, string $email): int
490  {
491  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log');
492  return (int)$queryBuilder
493  ->count('uid')
494  ->from('sys_log')
495  ->where(
496  $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(SystemLogType::LOGIN)),
497  $queryBuilder->expr()->eq('action', $queryBuilder->createNamedParameter(SystemLogLoginAction::PASSWORD_RESET_REQUEST)),
498  $queryBuilder->expr()->eq('log_data', $queryBuilder->createNamedParameter(serialize(['email' => $email]))),
499  $queryBuilder->expr()->gte('tstamp', $queryBuilder->createNamedParameter($since->getTimestamp(), ‪Connection::PARAM_INT))
500  )
501  ->executeQuery()
502  ->fetchOne();
503  }
504 
508  protected function ‪invalidateUserSessions(int $userId): void
509  {
510  $sessionManager = GeneralUtility::makeInstance(SessionManager::class);
511  $sessionBackend = $sessionManager->getSessionBackend('BE');
512  $sessionManager->invalidateAllSessionsByUserId($sessionBackend, $userId);
513  }
514 }
‪TYPO3\CMS\Backend\Authentication\PasswordReset\log
‪log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, Context $context)
Definition: PasswordReset.php:421
‪TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction
Definition: HiddenRestriction.php:27
‪TYPO3\CMS\Backend\Authentication\PasswordReset\sendResetEmail
‪sendResetEmail(ServerRequestInterface $request, Context $context, array $user, string $emailAddress)
Definition: PasswordReset.php:186
‪TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory
Definition: PasswordHashFactory.php:27
‪TYPO3\CMS\Backend\Authentication\PasswordReset\isValidResetTokenFromRequest
‪bool isValidResetTokenFromRequest(ServerRequestInterface $request)
Definition: PasswordReset.php:263
‪TYPO3\CMS\Core\Database\Connection\PARAM_INT
‪const PARAM_INT
Definition: Connection.php:49
‪TYPO3\CMS\Backend\Authentication\PasswordReset\resetPassword
‪bool resetPassword(ServerRequestInterface $request, Context $context)
Definition: PasswordReset.php:330
‪TYPO3\CMS\Backend\Authentication\PasswordReset\hasExceededMaximumAttemptsForReset
‪bool hasExceededMaximumAttemptsForReset(Context $context, string $email)
Definition: PasswordReset.php:475
‪TYPO3\CMS\Backend\Authentication
‪TYPO3\CMS\Backend\Authentication\PasswordReset\getHasher
‪getHasher()
Definition: PasswordReset.php:405
‪TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction
Definition: EndTimeRestriction.php:27
‪TYPO3\CMS\Core\Context\AspectInterface\get
‪mixed get(string $name)
‪TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction
Definition: StartTimeRestriction.php:27
‪TYPO3\CMS\Backend\Authentication\PasswordReset\getPreparedQueryBuilder
‪QueryBuilder getPreparedQueryBuilder()
Definition: PasswordReset.php:381
‪TYPO3\CMS\Backend\Authentication\PasswordReset
Definition: PasswordReset.php:59
‪TYPO3\CMS\Backend\Authentication\PasswordReset\initiateReset
‪initiateReset(ServerRequestInterface $request, Context $context, string $emailAddress)
Definition: PasswordReset.php:116
‪TYPO3\CMS\Backend\Authentication\PasswordReset\MAXIMUM_RESET_ATTEMPTS
‪const MAXIMUM_RESET_ATTEMPTS
Definition: PasswordReset.php:63
‪TYPO3\CMS\Core\Session\SessionManager
Definition: SessionManager.php:39
‪TYPO3\CMS\Backend\Authentication\PasswordReset\generateResetLinkForUser
‪UriInterface generateResetLinkForUser(Context $context, int $userId, string $emailAddress)
Definition: PasswordReset.php:230
‪$fields
‪$fields
Definition: pages.php:5
‪TYPO3\CMS\Core\Database\Connection\PARAM_STR
‪const PARAM_STR
Definition: Connection.php:54
‪TYPO3\CMS\Core\SysLog\Action\Login
Definition: Login.php:24
‪TYPO3\CMS\Core\Context\Context
Definition: Context.php:53
‪TYPO3\CMS\Backend\Authentication\PasswordReset\TOKEN_VALID_UNTIL
‪const TOKEN_VALID_UNTIL
Definition: PasswordReset.php:62
‪TYPO3\CMS\Backend\Authentication\PasswordReset\getNumberOfInitiatedResetsForEmail
‪int getNumberOfInitiatedResetsForEmail(\DateTimeInterface $since, string $email)
Definition: PasswordReset.php:489
‪TYPO3\CMS\Core\Mail\FluidEmail
Definition: FluidEmail.php:35
‪TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction
Definition: RootLevelRestriction.php:27
‪TYPO3\CMS\Backend\Routing\UriBuilder
Definition: UriBuilder.php:40
‪TYPO3\CMS\Core\SysLog\Error
Definition: Error.php:24
‪TYPO3\CMS\Core\Mail\Mailer
Definition: Mailer.php:38
‪TYPO3\CMS\Backend\Authentication\PasswordReset\findValidUserForToken
‪array null findValidUserForToken(string $token, string $identity, int $expirationTimestamp)
Definition: PasswordReset.php:281
‪TYPO3\CMS\Core\Context\Context\getAspect
‪AspectInterface getAspect(string $name)
Definition: Context.php:102
‪TYPO3\CMS\Backend\Authentication\PasswordReset\sendAmbiguousEmail
‪sendAmbiguousEmail(ServerRequestInterface $request, Context $context, string $emailAddress)
Definition: PasswordReset.php:154
‪TYPO3\CMS\Core\Database\Connection
Definition: Connection.php:38
‪TYPO3\CMS\Backend\Authentication\PasswordReset\isEnabledForUser
‪bool isEnabledForUser(int $userId)
Definition: PasswordReset.php:90
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:25
‪TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction
Definition: DeletedRestriction.php:28
‪TYPO3\CMS\Backend\Authentication\PasswordReset\invalidateUserSessions
‪invalidateUserSessions(int $userId)
Definition: PasswordReset.php:508
‪TYPO3\CMS\Backend\Authentication\PasswordReset\MAXIMUM_RESET_ATTEMPTS_SINCE
‪const MAXIMUM_RESET_ATTEMPTS_SINCE
Definition: PasswordReset.php:64
‪TYPO3\CMS\Core\Crypto\Random
Definition: Random.php:24
‪TYPO3\CMS\Core\Database\ConnectionPool
Definition: ConnectionPool.php:46
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:50
‪TYPO3\CMS\Backend\Routing\UriBuilder\ABSOLUTE_URL
‪const ABSOLUTE_URL
Definition: UriBuilder.php:44
‪TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface
Definition: PasswordHashInterface.php:25
‪TYPO3\CMS\Core\Http\NormalizedParams\createFromRequest
‪static static createFromRequest(ServerRequestInterface $request, array $systemConfiguration=null)
Definition: NormalizedParams.php:843
‪TYPO3\CMS\Backend\Authentication\PasswordReset\isEnabled
‪isEnabled()
Definition: PasswordReset.php:69
‪TYPO3\CMS\Core\Http\NormalizedParams
Definition: NormalizedParams.php:35
‪TYPO3\CMS\Core\SysLog\Type
Definition: Type.php:28