1<?php 2 3declare(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 18namespace TYPO3\CMS\Backend\Authentication; 19 20use Doctrine\DBAL\Platforms\MySqlPlatform; 21use Psr\Http\Message\ServerRequestInterface; 22use Psr\Http\Message\UriInterface; 23use Psr\Log\LoggerAwareInterface; 24use Psr\Log\LoggerAwareTrait; 25use Symfony\Component\Mime\Address; 26use TYPO3\CMS\Backend\Routing\UriBuilder; 27use TYPO3\CMS\Core\Context\Context; 28use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory; 29use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface; 30use TYPO3\CMS\Core\Crypto\Random; 31use TYPO3\CMS\Core\Database\ConnectionPool; 32use TYPO3\CMS\Core\Database\Query\QueryBuilder; 33use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; 34use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction; 35use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction; 36use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction; 37use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction; 38use TYPO3\CMS\Core\Http\NormalizedParams; 39use TYPO3\CMS\Core\Mail\FluidEmail; 40use TYPO3\CMS\Core\Mail\Mailer; 41use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction; 42use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification; 43use TYPO3\CMS\Core\SysLog\Type as SystemLogType; 44use TYPO3\CMS\Core\Utility\GeneralUtility; 45 46/** 47 * This class is responsible for 48 * - find the right user, sending out a reset email. 49 * - create a token for creating the link (not exposed outside of this class) 50 * - validate a hashed token 51 * - send out an email to initiate the password reset 52 * - update a password for a backend user if all parameters match 53 * 54 * @internal this is a concrete implementation for User/Password login and not part of public TYPO3 Core API. 55 */ 56class PasswordReset implements LoggerAwareInterface 57{ 58 use LoggerAwareTrait; 59 60 protected const TOKEN_VALID_UNTIL = '+2 hours'; 61 protected const MAXIMUM_RESET_ATTEMPTS = 3; 62 protected const MAXIMUM_RESET_ATTEMPTS_SINCE = '-30 minutes'; 63 64 /** 65 * Check if there are at least one in the system that contains a non-empty password AND an email address set. 66 */ 67 public function isEnabled(): bool 68 { 69 // Option not explicitly enabled 70 if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) { 71 return false; 72 } 73 $queryBuilder = $this->getPreparedQueryBuilder(); 74 $statement = $queryBuilder 75 ->select('uid') 76 ->from('be_users') 77 ->setMaxResults(1) 78 ->execute(); 79 return (int)$statement->fetchColumn() > 0; 80 } 81 82 /** 83 * Check if a specific backend user can be used to trigger an email reset. Basically checks if the functionality 84 * is enabled in general, and if the user has email + password set. 85 * 86 * @param int $userId 87 * @return bool 88 */ 89 public function isEnabledForUser(int $userId): bool 90 { 91 // Option not explicitly enabled 92 if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) { 93 return false; 94 } 95 $queryBuilder = $this->getPreparedQueryBuilder(); 96 $statement = $queryBuilder 97 ->select('uid') 98 ->from('be_users') 99 ->andWhere( 100 $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($userId, \PDO::PARAM_INT)) 101 ) 102 ->setMaxResults(1) 103 ->execute(); 104 return $statement->fetchColumn() > 0; 105 } 106 107 /** 108 * Determine the right user and send out an email. If multiple users are found with the same email address 109 * an alternative email is sent. 110 * 111 * If no user is found, this is logged to the system (but not to sys_log). 112 * 113 * The method intentionally does not return anything to avoid any information disclosure or exposure. 114 * 115 * @param ServerRequestInterface $request 116 * @param Context $context 117 * @param string $emailAddress 118 */ 119 public function initiateReset(ServerRequestInterface $request, Context $context, string $emailAddress): void 120 { 121 if (!GeneralUtility::validEmail($emailAddress)) { 122 return; 123 } 124 if ($this->hasExceededMaximumAttemptsForReset($context, $emailAddress)) { 125 $this->logger->alert('Password reset requested for email "' . $emailAddress . '" . but was requested too many times.'); 126 return; 127 } 128 $queryBuilder = $this->getPreparedQueryBuilder(); 129 $users = $queryBuilder 130 ->select('uid', 'email', 'username', 'realName', 'uc', 'lang') 131 ->from('be_users') 132 ->andWhere( 133 $queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($emailAddress)) 134 ) 135 ->execute() 136 ->fetchAll(); 137 if (!is_array($users) || count($users) === 0) { 138 // No user found, do nothing, also no log to sys_log in order avoid log flooding 139 $this->logger->warning('Password reset requested for email but no valid users'); 140 } elseif (count($users) > 1) { 141 // More than one user with the same email address found, send out the email that one cannot send out a reset link 142 $this->sendAmbiguousEmail($request, $context, $emailAddress); 143 } else { 144 $user = reset($users); 145 $this->sendResetEmail($request, $context, (array)$user, $emailAddress); 146 } 147 } 148 149 /** 150 * Send out an email to a given email address and note that a reset was triggered but email was used multiple times. 151 * Used when the database returned multiple users. 152 * 153 * @param ServerRequestInterface $request 154 * @param Context $context 155 * @param string $emailAddress 156 */ 157 protected function sendAmbiguousEmail(ServerRequestInterface $request, Context $context, string $emailAddress): void 158 { 159 $emailObject = GeneralUtility::makeInstance(FluidEmail::class); 160 $emailObject 161 ->to(new Address($emailAddress)) 162 ->setRequest($request) 163 ->assign('email', $emailAddress) 164 ->setTemplate('PasswordReset/AmbiguousResetRequested'); 165 166 GeneralUtility::makeInstance(Mailer::class)->send($emailObject); 167 $this->logger->warning('Password reset sent to email address ' . $emailAddress . ' but multiple accounts found'); 168 $this->log( 169 'Sent password reset email to email address %s but with multiple accounts attached.', 170 SystemLogLoginAction::PASSWORD_RESET_REQUEST, 171 SystemLogErrorClassification::WARNING, 172 0, 173 [ 174 'email' => $emailAddress 175 ], 176 NormalizedParams::createFromRequest($request)->getRemoteAddress(), 177 $context 178 ); 179 } 180 181 /** 182 * Send out an email to a user that does have an email address added to his account, containing a reset link. 183 * 184 * @param ServerRequestInterface $request 185 * @param Context $context 186 * @param array $user 187 * @param string $emailAddress 188 */ 189 protected function sendResetEmail(ServerRequestInterface $request, Context $context, array $user, string $emailAddress): void 190 { 191 $uc = unserialize($user['uc'] ?? '', ['allowed_classes' => false]); 192 $resetLink = $this->generateResetLinkForUser($context, (int)$user['uid'], (string)$user['email']); 193 $emailObject = GeneralUtility::makeInstance(FluidEmail::class); 194 $emailObject 195 ->to(new Address((string)$user['email'], $user['realName'])) 196 ->setRequest($request) 197 ->assign('name', $user['realName']) 198 ->assign('email', $user['email']) 199 ->assign('language', $uc['lang'] ?? $user['lang'] ?: 'default') 200 ->assign('resetLink', $resetLink) 201 ->setTemplate('PasswordReset/ResetRequested'); 202 203 GeneralUtility::makeInstance(Mailer::class)->send($emailObject); 204 $this->logger->info('Sent password reset email to email address ' . $emailAddress . ' for user ' . $user['username']); 205 $this->log( 206 'Sent password reset email to email address %s', 207 SystemLogLoginAction::PASSWORD_RESET_REQUEST, 208 SystemLogErrorClassification::SECURITY_NOTICE, 209 (int)$user['uid'], 210 [ 211 'email' => $user['email'] 212 ], 213 NormalizedParams::createFromRequest($request)->getRemoteAddress(), 214 $context 215 ); 216 } 217 218 /** 219 * Creates a token, stores it in the database, and then creates an absolute URL for resetting the password. 220 * This is all in one method so it is not exposed from the outside. 221 * 222 * This function requires: 223 * a) the user is allowed to do a password reset (no check is done anymore) 224 * b) a valid email address. 225 * 226 * @param Context $context 227 * @param int $userId the backend user uid 228 * @param string $emailAddress is part of the hash to ensure that the email address does not get reset. 229 * @return UriInterface 230 */ 231 protected function generateResetLinkForUser(Context $context, int $userId, string $emailAddress): UriInterface 232 { 233 $token = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96); 234 $currentTime = $context->getAspect('date')->getDateTime(); 235 $expiresOn = $currentTime->modify(self::TOKEN_VALID_UNTIL); 236 // Create a hash ("one time password") out of the token including the timestamp of the expiration date 237 $hash = GeneralUtility::hmac($token . '|' . (string)$expiresOn->getTimestamp() . '|' . $emailAddress . '|' . (string)$userId, 'password-reset'); 238 239 // Set the token in the database, which is hashed 240 GeneralUtility::makeInstance(ConnectionPool::class) 241 ->getConnectionForTable('be_users') 242 ->update('be_users', ['password_reset_token' => $this->getHasher()->getHashedPassword($hash)], ['uid' => $userId]); 243 244 return GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute( 245 'password_reset_validate', 246 [ 247 // "token" 248 't' => $token, 249 // "expiration date" 250 'e' => $expiresOn->getTimestamp(), 251 // "identity" 252 'i' => hash('sha1', $emailAddress . (string)$userId) 253 ], 254 UriBuilder::ABSOLUTE_URL 255 ); 256 } 257 258 /** 259 * Validates all query parameters / GET parameters of the given request against the token. 260 * 261 * @param ServerRequestInterface $request 262 * @return bool 263 */ 264 public function isValidResetTokenFromRequest(ServerRequestInterface $request): bool 265 { 266 $user = $this->findValidUserForToken( 267 (string)($request->getQueryParams()['t'] ?? ''), 268 (string)($request->getQueryParams()['i'] ?? ''), 269 (int)($request->getQueryParams()['e'] ?? 0) 270 ); 271 return $user !== null; 272 } 273 274 /** 275 * Fetch the user record from the database if the token is valid, and has matched all criteria 276 * 277 * @param string $token 278 * @param string $identity 279 * @param int $expirationTimestamp 280 * @return array|null the BE User database record 281 */ 282 protected function findValidUserForToken(string $token, string $identity, int $expirationTimestamp): ?array 283 { 284 $user = null; 285 // Find the token in the database 286 $queryBuilder = $this->getPreparedQueryBuilder(); 287 288 $queryBuilder 289 ->select('uid', 'email', 'password_reset_token') 290 ->from('be_users'); 291 if ($queryBuilder->getConnection()->getDatabasePlatform() instanceof MySqlPlatform) { 292 $queryBuilder->andWhere( 293 $queryBuilder->expr()->comparison('SHA1(CONCAT(' . $queryBuilder->quoteIdentifier('email') . ', ' . $queryBuilder->quoteIdentifier('uid') . '))', $queryBuilder->expr()::EQ, $queryBuilder->createNamedParameter($identity)) 294 ); 295 $user = $queryBuilder->execute()->fetch(); 296 } else { 297 // no native SHA1/ CONCAT functionality, has to be done in PHP 298 $stmt = $queryBuilder->execute(); 299 while ($row = $stmt->fetch()) { 300 if (hash_equals(hash('sha1', $row['email'] . (string)$row['uid']), $identity)) { 301 $user = $row; 302 break; 303 } 304 } 305 } 306 307 if (!is_array($user) || empty($user)) { 308 return null; 309 } 310 311 // Validate hash by rebuilding the hash from the parameters and the URL and see if this matches against the stored password_reset_token 312 $hash = GeneralUtility::hmac($token . '|' . (string)$expirationTimestamp . '|' . $user['email'] . '|' . (string)$user['uid'], 'password-reset'); 313 if (!$this->getHasher()->checkPassword($hash, $user['password_reset_token'] ?? '')) { 314 return null; 315 } 316 return $user; 317 } 318 319 /** 320 * Update the password in the database if the password matches and the token is valid. 321 * 322 * @param ServerRequestInterface $request 323 * @param Context $context current context 324 * @return bool whether the password was reset or not 325 */ 326 public function resetPassword(ServerRequestInterface $request, Context $context): bool 327 { 328 $expirationTimestamp = (int)($request->getQueryParams()['e'] ?? ''); 329 $identityHash = (string)($request->getQueryParams()['i'] ?? ''); 330 $token = (string)($request->getQueryParams()['t'] ?? ''); 331 $newPassword = (string)$request->getParsedBody()['password']; 332 $newPasswordRepeat = (string)$request->getParsedBody()['passwordrepeat']; 333 if (strlen($newPassword) < 8 || $newPassword !== $newPasswordRepeat) { 334 $this->logger->debug('Password reset not possible due to weak password'); 335 return false; 336 } 337 $user = $this->findValidUserForToken($token, $identityHash, $expirationTimestamp); 338 if ($user === null) { 339 $this->logger->warning('Password reset not possible. Valid user for token not found.'); 340 return false; 341 } 342 $userId = (int)$user['uid']; 343 344 GeneralUtility::makeInstance(ConnectionPool::class) 345 ->getConnectionForTable('be_users') 346 ->update('be_users', ['password_reset_token' => '', 'password' => $this->getHasher()->getHashedPassword($newPassword)], ['uid' => $userId]); 347 348 $this->logger->info('Password reset successful for user ' . $userId); 349 $this->log( 350 'Password reset successful for user %s', 351 SystemLogLoginAction::PASSWORD_RESET_ACCOMPLISHED, 352 SystemLogErrorClassification::SECURITY_NOTICE, 353 $userId, 354 [ 355 'email' => $user['email'], 356 'user' => $userId 357 ], 358 NormalizedParams::createFromRequest($request)->getRemoteAddress(), 359 $context 360 ); 361 return true; 362 } 363 364 /** 365 * The querybuilder for finding the right user - and adds some restrictions: 366 * - No CLI users 367 * - No Admin users (with option) 368 * - No hidden/deleted users 369 * - Password must be set 370 * - Username must be set 371 * - Email address must be set 372 * 373 * @return QueryBuilder 374 */ 375 protected function getPreparedQueryBuilder(): QueryBuilder 376 { 377 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users'); 378 $queryBuilder->getRestrictions() 379 ->removeAll() 380 ->add(GeneralUtility::makeInstance(RootLevelRestriction::class)) 381 ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) 382 ->add(GeneralUtility::makeInstance(StartTimeRestriction::class)) 383 ->add(GeneralUtility::makeInstance(EndTimeRestriction::class)) 384 ->add(GeneralUtility::makeInstance(HiddenRestriction::class)); 385 $queryBuilder->where( 386 $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('')), 387 $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('_cli_')), 388 $queryBuilder->expr()->neq('password', $queryBuilder->createNamedParameter('')), 389 $queryBuilder->expr()->neq('email', $queryBuilder->createNamedParameter('')) 390 ); 391 if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] ?? false)) { 392 $queryBuilder->andWhere( 393 $queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)) 394 ); 395 } 396 return $queryBuilder; 397 } 398 399 protected function getHasher(): PasswordHashInterface 400 { 401 return GeneralUtility::makeInstance(PasswordHashFactory::class)->getDefaultHashInstance('BE'); 402 } 403 404 /** 405 * Adds an entry to "sys_log", also used to track the maximum allowed attempts. 406 * 407 * @param string $message the information / message in english 408 * @param int $action see SystemLogLoginAction 409 * @param int $error see SystemLogErrorClassification 410 * @param int $userId 411 * @param array $data additional information, used for the message 412 * @param string $ipAddress 413 * @param Context $context 414 */ 415 protected function log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, Context $context): void 416 { 417 $fields = [ 418 'userid' => $userId, 419 'type' => SystemLogType::LOGIN, 420 'action' => $action, 421 'error' => $error, 422 'details_nr' => 1, 423 'details' => $message, 424 'log_data' => serialize($data), 425 'tablename' => 'be_users', 426 'recuid' => $userId, 427 'IP' => (string)$ipAddress, 428 'tstamp' => $context->getAspect('date')->get('timestamp'), 429 'event_pid' => 0, 430 'NEWid' => '', 431 'workspace' => 0 432 ]; 433 434 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_log'); 435 $connection->insert( 436 'sys_log', 437 $fields, 438 [ 439 \PDO::PARAM_INT, 440 \PDO::PARAM_INT, 441 \PDO::PARAM_INT, 442 \PDO::PARAM_INT, 443 \PDO::PARAM_INT, 444 \PDO::PARAM_STR, 445 \PDO::PARAM_STR, 446 \PDO::PARAM_STR, 447 \PDO::PARAM_INT, 448 \PDO::PARAM_STR, 449 \PDO::PARAM_INT, 450 \PDO::PARAM_INT, 451 \PDO::PARAM_STR, 452 \PDO::PARAM_STR, 453 ] 454 ); 455 } 456 457 /** 458 * Checks if an email reset link has been requested more than 3 times in the last 30mins. 459 * If a password was successfully reset more than three times in 30 minutes, it would still fail. 460 * 461 * @param Context $context 462 * @param string $email 463 * @return bool 464 */ 465 protected function hasExceededMaximumAttemptsForReset(Context $context, string $email): bool 466 { 467 $now = $context->getAspect('date')->getDateTime(); 468 $numberOfAttempts = $this->getNumberOfInitiatedResetsForEmail($now->modify(self::MAXIMUM_RESET_ATTEMPTS_SINCE), $email); 469 return $numberOfAttempts > self::MAXIMUM_RESET_ATTEMPTS; 470 } 471 472 /** 473 * SQL query to find the amount of initiated resets from a given time. 474 * 475 * @param \DateTimeInterface $since 476 * @param string $email 477 * @return int 478 */ 479 protected function getNumberOfInitiatedResetsForEmail(\DateTimeInterface $since, string $email): int 480 { 481 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log'); 482 return (int)$queryBuilder 483 ->count('uid') 484 ->from('sys_log') 485 ->where( 486 $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(SystemLogType::LOGIN)), 487 $queryBuilder->expr()->eq('action', $queryBuilder->createNamedParameter(SystemLogLoginAction::PASSWORD_RESET_REQUEST)), 488 $queryBuilder->expr()->eq('log_data', $queryBuilder->createNamedParameter(serialize(['email' => $email]))), 489 $queryBuilder->expr()->gte('tstamp', $queryBuilder->createNamedParameter($since->getTimestamp(), \PDO::PARAM_INT)) 490 ) 491 ->execute() 492 ->fetchColumn(0); 493 } 494} 495