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