‪TYPO3CMS  10.4
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;
42 use ‪TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
43 use ‪TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
44 use ‪TYPO3\CMS\Core\SysLog\Type as SystemLogType;
46 
57 class ‪PasswordReset implements LoggerAwareInterface
58 {
59  use LoggerAwareTrait;
60 
61  protected const ‪TOKEN_VALID_UNTIL = '+2 hours';
62  protected const ‪MAXIMUM_RESET_ATTEMPTS = 3;
63  protected const ‪MAXIMUM_RESET_ATTEMPTS_SINCE = '-30 minutes';
64 
68  public function ‪isEnabled(): bool
69  {
70  // Option not explicitly enabled
71  if (!(‪$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
72  return false;
73  }
74  $queryBuilder = $this->‪getPreparedQueryBuilder();
75  $statement = $queryBuilder
76  ->select('uid')
77  ->from('be_users')
78  ->setMaxResults(1)
79  ->execute();
80  return (int)$statement->fetchColumn() > 0;
81  }
82 
90  public function ‪isEnabledForUser(int $userId): bool
91  {
92  // Option not explicitly enabled
93  if (!(‪$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
94  return false;
95  }
96  $queryBuilder = $this->‪getPreparedQueryBuilder();
97  $statement = $queryBuilder
98  ->select('uid')
99  ->from('be_users')
100  ->andWhere(
101  $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($userId, \PDO::PARAM_INT))
102  )
103  ->setMaxResults(1)
104  ->execute();
105  return $statement->fetchColumn() > 0;
106  }
107 
120  public function ‪initiateReset(ServerRequestInterface $request, ‪Context $context, string $emailAddress): void
121  {
122  if (!GeneralUtility::validEmail($emailAddress)) {
123  return;
124  }
125  if ($this->‪hasExceededMaximumAttemptsForReset($context, $emailAddress)) {
126  $this->logger->alert('Password reset requested for email "' . $emailAddress . '" . but was requested too many times.');
127  return;
128  }
129  $queryBuilder = $this->‪getPreparedQueryBuilder();
130  $users = $queryBuilder
131  ->select('uid', 'email', 'username', 'realName', 'uc', 'lang')
132  ->from('be_users')
133  ->andWhere(
134  $queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($emailAddress))
135  )
136  ->execute()
137  ->fetchAll();
138  if (!is_array($users) || count($users) === 0) {
139  // No user found, do nothing, also no log to sys_log in order avoid log flooding
140  $this->logger->warning('Password reset requested for email but no valid users');
141  } elseif (count($users) > 1) {
142  // More than one user with the same email address found, send out the email that one cannot send out a reset link
143  $this->‪sendAmbiguousEmail($request, $context, $emailAddress);
144  } else {
145  $user = reset($users);
146  $this->‪sendResetEmail($request, $context, (array)$user, $emailAddress);
147  }
148  }
149 
158  protected function ‪sendAmbiguousEmail(ServerRequestInterface $request, ‪Context $context, string $emailAddress): void
159  {
160  $emailObject = GeneralUtility::makeInstance(FluidEmail::class);
161  $emailObject
162  ->to(new Address($emailAddress))
163  ->setRequest($request)
164  ->assign('email', $emailAddress)
165  ->setTemplate('PasswordReset/AmbiguousResetRequested');
166 
167  GeneralUtility::makeInstance(Mailer::class)->send($emailObject);
168  $this->logger->warning('Password reset sent to email address ' . $emailAddress . ' but multiple accounts found');
169  $this->‪log(
170  'Sent password reset email to email address %s but with multiple accounts attached.',
171  SystemLogLoginAction::PASSWORD_RESET_REQUEST,
172  SystemLogErrorClassification::WARNING,
173  0,
174  [
175  'email' => $emailAddress
176  ],
177  ‪NormalizedParams::createFromRequest($request)->getRemoteAddress(),
178  $context
179  );
180  }
181 
190  protected function ‪sendResetEmail(ServerRequestInterface $request, ‪Context $context, array $user, string $emailAddress): void
191  {
192  $uc = unserialize($user['uc'] ?? '', ['allowed_classes' => false]);
193  $resetLink = $this->‪generateResetLinkForUser($context, (int)$user['uid'], (string)$user['email']);
194  $emailObject = GeneralUtility::makeInstance(FluidEmail::class);
195  $emailObject
196  ->to(new Address((string)$user['email'], $user['realName']))
197  ->setRequest($request)
198  ->assign('name', $user['realName'])
199  ->assign('email', $user['email'])
200  ->assign('language', $uc['lang'] ?? $user['lang'] ?: 'default')
201  ->assign('resetLink', $resetLink)
202  ->setTemplate('PasswordReset/ResetRequested');
203 
204  GeneralUtility::makeInstance(Mailer::class)->send($emailObject);
205  $this->logger->info('Sent password reset email to email address ' . $emailAddress . ' for user ' . $user['username']);
206  $this->‪log(
207  'Sent password reset email to email address %s',
208  SystemLogLoginAction::PASSWORD_RESET_REQUEST,
209  SystemLogErrorClassification::SECURITY_NOTICE,
210  (int)$user['uid'],
211  [
212  'email' => $user['email']
213  ],
214  ‪NormalizedParams::createFromRequest($request)->getRemoteAddress(),
215  $context
216  );
217  }
218 
232  protected function ‪generateResetLinkForUser(‪Context $context, int $userId, string $emailAddress): UriInterface
233  {
234  $token = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96);
235  $currentTime = $context->‪getAspect('date')->getDateTime();
236  $expiresOn = $currentTime->modify(self::TOKEN_VALID_UNTIL);
237  // Create a hash ("one time password") out of the token including the timestamp of the expiration date
238  $hash = GeneralUtility::hmac($token . '|' . (string)$expiresOn->getTimestamp() . '|' . $emailAddress . '|' . (string)$userId, 'password-reset');
239 
240  // Set the token in the database, which is hashed
241  GeneralUtility::makeInstance(ConnectionPool::class)
242  ->getConnectionForTable('be_users')
243  ->update('be_users', ['password_reset_token' => $this->‪getHasher()->getHashedPassword($hash)], ['uid' => $userId]);
244 
245  return GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute(
246  'password_reset_validate',
247  [
248  // "token"
249  't' => $token,
250  // "expiration date"
251  'e' => $expiresOn->getTimestamp(),
252  // "identity"
253  'i' => hash('sha1', $emailAddress . (string)$userId)
254  ],
256  );
257  }
258 
265  public function ‪isValidResetTokenFromRequest(ServerRequestInterface $request): bool
266  {
267  $user = $this->‪findValidUserForToken(
268  (string)($request->getQueryParams()['t'] ?? ''),
269  (string)($request->getQueryParams()['i'] ?? ''),
270  (int)($request->getQueryParams()['e'] ?? 0)
271  );
272  return $user !== null;
273  }
274 
283  protected function ‪findValidUserForToken(string $token, string $identity, int $expirationTimestamp): ?array
284  {
285  // Early return if token expired
286  if ($expirationTimestamp < time()) {
287  return null;
288  }
289 
290  $user = null;
291  // Find the token in the database
292  $queryBuilder = $this->‪getPreparedQueryBuilder();
293 
294  $queryBuilder
295  ->select('uid', 'email', 'password_reset_token')
296  ->from('be_users');
297  if ($queryBuilder->getConnection()->getDatabasePlatform() instanceof MySqlPlatform) {
298  $queryBuilder->andWhere(
299  $queryBuilder->expr()->comparison('SHA1(CONCAT(' . $queryBuilder->quoteIdentifier('email') . ', ' . $queryBuilder->quoteIdentifier('uid') . '))', $queryBuilder->expr()::EQ, $queryBuilder->createNamedParameter($identity))
300  );
301  $user = $queryBuilder->execute()->fetch();
302  } else {
303  // no native SHA1/ CONCAT functionality, has to be done in PHP
304  $stmt = $queryBuilder->execute();
305  while ($row = $stmt->fetch()) {
306  if (hash_equals(hash('sha1', $row['email'] . (string)$row['uid']), $identity)) {
307  $user = $row;
308  break;
309  }
310  }
311  }
312 
313  if (!is_array($user) || empty($user)) {
314  return null;
315  }
316 
317  // Validate hash by rebuilding the hash from the parameters and the URL and see if this matches against the stored password_reset_token
318  $hash = GeneralUtility::hmac($token . '|' . (string)$expirationTimestamp . '|' . $user['email'] . '|' . (string)$user['uid'], 'password-reset');
319  if (!$this->‪getHasher()->checkPassword($hash, $user['password_reset_token'] ?? '')) {
320  return null;
321  }
322  return $user;
323  }
324 
332  public function ‪resetPassword(ServerRequestInterface $request, ‪Context $context): bool
333  {
334  $expirationTimestamp = (int)($request->getQueryParams()['e'] ?? '');
335  $identityHash = (string)($request->getQueryParams()['i'] ?? '');
336  $token = (string)($request->getQueryParams()['t'] ?? '');
337  $newPassword = (string)$request->getParsedBody()['password'];
338  $newPasswordRepeat = (string)$request->getParsedBody()['passwordrepeat'];
339  if (strlen($newPassword) < 8 || $newPassword !== $newPasswordRepeat) {
340  $this->logger->debug('Password reset not possible due to weak password');
341  return false;
342  }
343  $user = $this->‪findValidUserForToken($token, $identityHash, $expirationTimestamp);
344  if ($user === null) {
345  $this->logger->warning('Password reset not possible. Valid user for token not found.');
346  return false;
347  }
348  $userId = (int)$user['uid'];
349 
350  GeneralUtility::makeInstance(ConnectionPool::class)
351  ->getConnectionForTable('be_users')
352  ->update('be_users', ['password_reset_token' => '', 'password' => $this->‪getHasher()->getHashedPassword($newPassword)], ['uid' => $userId]);
353 
354  $this->‪invalidateUserSessions($userId);
355 
356  $this->logger->info('Password reset successful for user ' . $userId);
357  $this->‪log(
358  'Password reset successful for user %s',
359  SystemLogLoginAction::PASSWORD_RESET_ACCOMPLISHED,
360  SystemLogErrorClassification::SECURITY_NOTICE,
361  $userId,
362  [
363  'email' => $user['email'],
364  'user' => $userId
365  ],
366  ‪NormalizedParams::createFromRequest($request)->getRemoteAddress(),
367  $context
368  );
369  return true;
370  }
371 
384  {
385  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
386  $queryBuilder->‪getRestrictions()
387  ->‪removeAll()
388  ->‪add(GeneralUtility::makeInstance(RootLevelRestriction::class))
389  ->‪add(GeneralUtility::makeInstance(DeletedRestriction::class))
390  ->‪add(GeneralUtility::makeInstance(StartTimeRestriction::class))
391  ->‪add(GeneralUtility::makeInstance(EndTimeRestriction::class))
392  ->‪add(GeneralUtility::makeInstance(HiddenRestriction::class));
393  $queryBuilder->where(
394  $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('')),
395  $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('_cli_')),
396  $queryBuilder->expr()->neq('password', $queryBuilder->createNamedParameter('')),
397  $queryBuilder->expr()->neq('email', $queryBuilder->createNamedParameter(''))
398  );
399  if (!(‪$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] ?? false)) {
400  $queryBuilder->andWhere(
401  $queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
402  );
403  }
404  return $queryBuilder;
405  }
406 
408  {
409  return GeneralUtility::makeInstance(PasswordHashFactory::class)->getDefaultHashInstance('BE');
410  }
411 
423  protected function ‪log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, ‪Context $context): void
424  {
425  ‪$fields = [
426  'userid' => $userId,
427  'type' => 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  [
447  \PDO::PARAM_INT,
448  \PDO::PARAM_INT,
449  \PDO::PARAM_INT,
450  \PDO::PARAM_INT,
451  \PDO::PARAM_INT,
452  \PDO::PARAM_STR,
453  \PDO::PARAM_STR,
454  \PDO::PARAM_STR,
455  \PDO::PARAM_INT,
456  \PDO::PARAM_STR,
457  \PDO::PARAM_INT,
458  \PDO::PARAM_INT,
459  \PDO::PARAM_STR,
460  \PDO::PARAM_STR,
461  ]
462  );
463  }
464 
473  protected function ‪hasExceededMaximumAttemptsForReset(‪Context $context, string $email): bool
474  {
475  $now = $context->‪getAspect('date')->getDateTime();
476  $numberOfAttempts = $this->‪getNumberOfInitiatedResetsForEmail($now->modify(self::MAXIMUM_RESET_ATTEMPTS_SINCE), $email);
477  return $numberOfAttempts > ‪self::MAXIMUM_RESET_ATTEMPTS;
478  }
479 
487  protected function ‪getNumberOfInitiatedResetsForEmail(\DateTimeInterface $since, string $email): int
488  {
489  $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log');
490  return (int)$queryBuilder
491  ->count('uid')
492  ->from('sys_log')
493  ->where(
494  $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(SystemLogType::LOGIN)),
495  $queryBuilder->expr()->eq('action', $queryBuilder->createNamedParameter(SystemLogLoginAction::PASSWORD_RESET_REQUEST)),
496  $queryBuilder->expr()->eq('log_data', $queryBuilder->createNamedParameter(serialize(['email' => $email]))),
497  $queryBuilder->expr()->gte('tstamp', $queryBuilder->createNamedParameter($since->getTimestamp(), \PDO::PARAM_INT))
498  )
499  ->execute()
500  ->fetchColumn(0);
501  }
502 
506  protected function ‪invalidateUserSessions(int $userId): void
507  {
508  $sessionManager = GeneralUtility::makeInstance(SessionManager::class);
509  $sessionBackend = $sessionManager->getSessionBackend('BE');
510  $sessionManager->invalidateAllSessionsByUserId($sessionBackend, $userId);
511  }
512 }
‪TYPO3\CMS\Backend\Authentication\PasswordReset\log
‪log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, Context $context)
Definition: PasswordReset.php:423
‪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:190
‪TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory
Definition: PasswordHashFactory.php:27
‪TYPO3\CMS\Backend\Authentication\PasswordReset\isValidResetTokenFromRequest
‪bool isValidResetTokenFromRequest(ServerRequestInterface $request)
Definition: PasswordReset.php:265
‪TYPO3\CMS\Backend\Authentication\PasswordReset\resetPassword
‪bool resetPassword(ServerRequestInterface $request, Context $context)
Definition: PasswordReset.php:332
‪TYPO3\CMS\Backend\Authentication\PasswordReset\hasExceededMaximumAttemptsForReset
‪bool hasExceededMaximumAttemptsForReset(Context $context, string $email)
Definition: PasswordReset.php:473
‪TYPO3\CMS\Backend\Authentication
‪TYPO3\CMS\Backend\Authentication\PasswordReset\getHasher
‪getHasher()
Definition: PasswordReset.php:407
‪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:383
‪TYPO3\CMS\Backend\Authentication\PasswordReset
Definition: PasswordReset.php:58
‪TYPO3\CMS\Backend\Authentication\PasswordReset\initiateReset
‪initiateReset(ServerRequestInterface $request, Context $context, string $emailAddress)
Definition: PasswordReset.php:120
‪TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface\removeAll
‪QueryRestrictionContainerInterface removeAll()
‪TYPO3\CMS\Backend\Authentication\PasswordReset\MAXIMUM_RESET_ATTEMPTS
‪const MAXIMUM_RESET_ATTEMPTS
Definition: PasswordReset.php:62
‪TYPO3\CMS\Core\Session\SessionManager
Definition: SessionManager.php:39
‪TYPO3\CMS\Core\Database\Query\QueryBuilder\getRestrictions
‪QueryRestrictionContainerInterface getRestrictions()
Definition: QueryBuilder.php:104
‪TYPO3\CMS\Backend\Authentication\PasswordReset\generateResetLinkForUser
‪UriInterface generateResetLinkForUser(Context $context, int $userId, string $emailAddress)
Definition: PasswordReset.php:232
‪$fields
‪$fields
Definition: pages.php:5
‪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:61
‪TYPO3\CMS\Core\Database\Query\QueryBuilder
Definition: QueryBuilder.php:52
‪TYPO3\CMS\Backend\Authentication\PasswordReset\getNumberOfInitiatedResetsForEmail
‪int getNumberOfInitiatedResetsForEmail(\DateTimeInterface $since, string $email)
Definition: PasswordReset.php:487
‪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:38
‪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:283
‪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:158
‪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:5
‪TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction
Definition: DeletedRestriction.php:28
‪TYPO3\CMS\Backend\Authentication\PasswordReset\invalidateUserSessions
‪invalidateUserSessions(int $userId)
Definition: PasswordReset.php:506
‪TYPO3\CMS\Backend\Authentication\PasswordReset\MAXIMUM_RESET_ATTEMPTS_SINCE
‪const MAXIMUM_RESET_ATTEMPTS_SINCE
Definition: PasswordReset.php:63
‪TYPO3\CMS\Core\Crypto\Random
Definition: Random.php:24
‪TYPO3\CMS\Core\Database\ConnectionPool
Definition: ConnectionPool.php:46
‪TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface\add
‪QueryRestrictionContainerInterface add(QueryRestrictionInterface $restriction)
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:46
‪TYPO3\CMS\Backend\Routing\UriBuilder\ABSOLUTE_URL
‪const ABSOLUTE_URL
Definition: UriBuilder.php:42
‪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:848
‪TYPO3\CMS\Backend\Authentication\PasswordReset\isEnabled
‪isEnabled()
Definition: PasswordReset.php:68
‪TYPO3\CMS\Core\Http\NormalizedParams
Definition: NormalizedParams.php:35
‪TYPO3\CMS\Core\SysLog\Type
Definition: Type.php:24