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