Passed
Push — master ( 4ec39a...24c59a )
by
unknown
15:04
created

PasswordReset::findValidUserForToken()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 35
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 21
nc 12
nop 3
dl 0
loc 35
rs 8.6506
c 1
b 0
f 0
1
<?php
2
declare(strict_types = 1);
3
namespace TYPO3\CMS\Backend\Authentication;
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
18
use Doctrine\DBAL\Platforms\MySqlPlatform;
19
use Psr\Http\Message\ServerRequestInterface;
20
use Psr\Http\Message\UriInterface;
21
use Psr\Log\LoggerAwareInterface;
22
use Psr\Log\LoggerAwareTrait;
23
use Symfony\Component\Mime\Address;
24
use TYPO3\CMS\Backend\Routing\UriBuilder;
25
use TYPO3\CMS\Core\Context\Context;
26
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
27
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface;
28
use TYPO3\CMS\Core\Crypto\Random;
29
use TYPO3\CMS\Core\Database\ConnectionPool;
30
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
31
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
32
use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction;
33
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
34
use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
35
use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
36
use TYPO3\CMS\Core\Http\NormalizedParams;
37
use TYPO3\CMS\Core\Mail\FluidEmail;
38
use TYPO3\CMS\Core\Mail\Mailer;
39
use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
40
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
41
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
42
use TYPO3\CMS\Core\Utility\GeneralUtility;
43
44
/**
45
 * This class is responsible for
46
 * - find the right user, sending out a reset email.
47
 * - create a token for creating the link (not exposed outside of this class)
48
 * - validate a hashed token
49
 * - send out an email to initiate the password reset
50
 * - update a password for a backend user if all parameters match
51
 *
52
 * @internal this is a concrete implementation for User/Password login and not part of public TYPO3 Core API.
53
 */
54
class PasswordReset implements LoggerAwareInterface
55
{
56
    use LoggerAwareTrait;
57
58
    protected const TOKEN_VALID_UNTIL = '+2 hours';
59
    protected const MAXIMUM_RESET_ATTEMPTS = 3;
60
    protected const MAXIMUM_RESET_ATTEMPTS_SINCE = '-30 minutes';
61
62
    /**
63
     * Check if there are at least one in the system that contains a non-empty password AND an email address set.
64
     */
65
    public function isEnabled(): bool
66
    {
67
        // Option not explicitly enabled
68
        if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
69
            return false;
70
        }
71
        $queryBuilder = $this->getPreparedQueryBuilder();
72
        $statement = $queryBuilder
73
            ->select('uid')
74
            ->from('be_users')
75
            ->setMaxResults(1)
76
            ->execute();
77
        return (int)$statement->fetchColumn() > 0;
78
    }
79
80
    /**
81
     * Check if a specific backend user can be used to trigger an email reset. Basically checks if the functionality
82
     * is enabled in general, and if the user has email + password set.
83
     *
84
     * @param int $userId
85
     * @return bool
86
     */
87
    public function isEnabledForUser(int $userId): bool
88
    {
89
        // Option not explicitly enabled
90
        if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
91
            return false;
92
        }
93
        $queryBuilder = $this->getPreparedQueryBuilder();
94
        $statement = $queryBuilder
95
            ->select('uid')
96
            ->from('be_users')
97
            ->andWhere(
98
                $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($userId, \PDO::PARAM_INT))
99
            )
100
            ->setMaxResults(1)
101
            ->execute();
102
        return $statement->fetchColumn() > 0;
103
    }
104
105
    /**
106
     * Determine the right user and send out an email. If multiple users are found with the same email address
107
     * an alternative email is sent.
108
     *
109
     * If no user is found, this is logged to the system (but not to sys_log).
110
     *
111
     * The method intentionally does not return anything to avoid any information disclosure or exposure.
112
     *
113
     * @param ServerRequestInterface $request
114
     * @param Context $context
115
     * @param string $emailAddress
116
     */
117
    public function initiateReset(ServerRequestInterface $request, Context $context, string $emailAddress): void
118
    {
119
        if (!GeneralUtility::validEmail($emailAddress)) {
120
            return;
121
        }
122
        if ($this->hasExceededMaximumAttemptsForReset($context, $emailAddress)) {
123
            $this->logger->alert('Password reset requested for email "' . $emailAddress . '" . but was requested too many times.');
124
            return;
125
        }
126
        $queryBuilder = $this->getPreparedQueryBuilder();
127
        $users = $queryBuilder
128
            ->select('uid', 'email', 'username', 'realName', 'uc', 'lang')
129
            ->from('be_users')
130
            ->andWhere(
131
                $queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($emailAddress))
132
            )
133
            ->execute()
134
            ->fetchAll();
135
        if (!is_array($users) || count($users) === 0) {
0 ignored issues
show
introduced by
The condition is_array($users) is always true.
Loading history...
136
            // No user found, do nothing, also no log to sys_log in order avoid log flooding
137
            $this->logger->warning('Password reset requested for email but no valid users');
138
        } elseif (count($users) > 1) {
139
            // More than one user with the same email address found, send out the email that one cannot send out a reset link
140
            $this->sendAmbiguousEmail($request, $context, $emailAddress);
141
        } else {
142
            $user = reset($users);
143
            $this->sendResetEmail($request, $context, (array)$user, $emailAddress);
144
        }
145
    }
146
147
    /**
148
     * Send out an email to a given email address and note that a reset was triggered but email was used multiple times.
149
     * Used when the database returned multiple users.
150
     *
151
     * @param ServerRequestInterface $request
152
     * @param Context $context
153
     * @param string $emailAddress
154
     */
155
    protected function sendAmbiguousEmail(ServerRequestInterface $request, Context $context, string $emailAddress): void
156
    {
157
        $emailObject = GeneralUtility::makeInstance(FluidEmail::class);
158
        $emailObject
159
            ->to(new Address($emailAddress))
160
            ->setRequest($request)
161
            ->assign('email', $emailAddress)
162
            ->setTemplate('PasswordReset/AmbiguousResetRequested');
163
164
        GeneralUtility::makeInstance(Mailer::class)->send($emailObject);
165
        $this->logger->warning('Password reset sent to email address ' . $emailAddress . ' but multiple accounts found');
166
        $this->log(
167
            'Sent password reset email to email address %s but with multiple accounts attached.',
168
            SystemLogLoginAction::PASSWORD_RESET_REQUEST,
169
            SystemLogErrorClassification::WARNING,
170
            0,
171
            [
172
                'email' => $emailAddress
173
            ],
174
            NormalizedParams::createFromRequest($request)->getRemoteAddress(),
175
            $context
176
        );
177
    }
178
179
    /**
180
     * Send out an email to a user that does have an email address added to his account, containing a reset link.
181
     *
182
     * @param ServerRequestInterface $request
183
     * @param Context $context
184
     * @param array $user
185
     * @param string $emailAddress
186
     */
187
    protected function sendResetEmail(ServerRequestInterface $request, Context $context, array $user, string $emailAddress): void
188
    {
189
        $uc = unserialize($user['uc'] ?? '', ['allowed_classes' => false]);
190
        $resetLink = $this->generateResetLinkForUser($context, (int)$user['uid'], (string)$user['email']);
191
        $emailObject = GeneralUtility::makeInstance(FluidEmail::class);
192
        $emailObject
193
            ->to(new Address((string)$user['email'], $user['realName']))
194
            ->setRequest($request)
195
            ->assign('name', $user['realName'])
196
            ->assign('email', $user['email'])
197
            ->assign('language', $uc['lang'] ?? $user['lang'] ?: 'default')
198
            ->assign('resetLink', $resetLink)
199
            ->setTemplate('PasswordReset/ResetRequested');
200
201
        GeneralUtility::makeInstance(Mailer::class)->send($emailObject);
202
        $this->logger->info('Sent password reset email to email address ' . $emailAddress . ' for user ' . $user['username']);
203
        $this->log(
204
            'Sent password reset email to email address %s',
205
            SystemLogLoginAction::PASSWORD_RESET_REQUEST,
206
            SystemLogErrorClassification::SECURITY_NOTICE,
207
            (int)$user['uid'],
208
            [
209
                'email' => $user['email']
210
            ],
211
            NormalizedParams::createFromRequest($request)->getRemoteAddress(),
212
            $context
213
        );
214
    }
215
216
    /**
217
     * Creates a token, stores it in the database, and then creates an absolute URL for resetting the password.
218
     * This is all in one method so it is not exposed from the outside.
219
     *
220
     * This function requires:
221
     * a) the user is allowed to do a password reset (no check is done anymore)
222
     * b) a valid email address.
223
     *
224
     * @param Context $context
225
     * @param int $userId the backend user uid
226
     * @param string $emailAddress is part of the hash to ensure that the email address does not get reset.
227
     * @return UriInterface
228
     */
229
    protected function generateResetLinkForUser(Context $context, int $userId, string $emailAddress): UriInterface
230
    {
231
        $token = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96);
232
        $currentTime = $context->getAspect('date')->getDateTime();
0 ignored issues
show
Bug introduced by
The method getDateTime() does not exist on TYPO3\CMS\Core\Context\AspectInterface. It seems like you code against a sub-type of TYPO3\CMS\Core\Context\AspectInterface such as TYPO3\CMS\Core\Context\DateTimeAspect. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

232
        $currentTime = $context->getAspect('date')->/** @scrutinizer ignore-call */ getDateTime();
Loading history...
233
        $expiresOn = $currentTime->modify(self::TOKEN_VALID_UNTIL);
234
        // Create a hash ("one time password") out of the token including the timestamp of the expiration date
235
        $hash = GeneralUtility::hmac($token . '|' . (string)$expiresOn->getTimestamp() . '|' . $emailAddress . '|' . (string)$userId, 'password-reset');
236
237
        // Set the token in the database, which is hashed
238
        GeneralUtility::makeInstance(ConnectionPool::class)
239
            ->getConnectionForTable('be_users')
240
            ->update('be_users', ['password_reset_token' => $this->getHasher()->getHashedPassword($hash)], ['uid' => $userId]);
241
242
        return GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute(
243
            'password_reset_validate',
244
            [
245
                // "token"
246
                't' => $token,
247
                // "expiration date"
248
                'e' => $expiresOn->getTimestamp(),
249
                // "identity"
250
                'i' => hash('sha1', $emailAddress . (string)$userId)
251
            ],
252
            UriBuilder::ABSOLUTE_URL
253
        );
254
    }
255
256
    /**
257
     * Validates all query parameters / GET parameters of the given request against the token.
258
     *
259
     * @param ServerRequestInterface $request
260
     * @return bool
261
     */
262
    public function isValidResetTokenFromRequest(ServerRequestInterface $request): bool
263
    {
264
        $user = $this->findValidUserForToken(
265
            (string)($request->getQueryParams()['t'] ?? ''),
266
            (string)($request->getQueryParams()['i'] ?? ''),
267
            (int)($request->getQueryParams()['e'] ?? 0)
268
        );
269
        return $user !== null;
270
    }
271
272
    /**
273
     * Fetch the user record from the database if the token is valid, and has matched all criteria
274
     *
275
     * @param string $token
276
     * @param string $identity
277
     * @param int $expirationTimestamp
278
     * @return array|null the BE User database record
279
     */
280
    protected function findValidUserForToken(string $token, string $identity, int $expirationTimestamp): ?array
281
    {
282
        $user = null;
283
        // Find the token in the database
284
        $queryBuilder = $this->getPreparedQueryBuilder();
285
286
        $queryBuilder
287
            ->select('uid', 'email', 'password_reset_token')
288
            ->from('be_users');
289
        if ($queryBuilder->getConnection()->getDatabasePlatform() instanceof MySqlPlatform) {
290
            $queryBuilder->andWhere(
291
                $queryBuilder->expr()->comparison('SHA1(CONCAT(' . $queryBuilder->quoteIdentifier('email') . ', ' . $queryBuilder->quoteIdentifier('uid') . '))', $queryBuilder->expr()::EQ, $queryBuilder->createNamedParameter($identity))
292
            );
293
            $user = $queryBuilder->execute()->fetch();
294
        } else {
295
            // no native SHA1/ CONCAT functionality, has to be done in PHP
296
            $stmt = $queryBuilder->execute();
297
            while ($row = $stmt->fetch()) {
298
                if (hash('sha1', $row['email'] . (string)$row['uid']) === $identity) {
299
                    $user = $row;
300
                    break;
301
                }
302
            }
303
        }
304
305
        if (!is_array($user) || empty($user)) {
306
            return null;
307
        }
308
309
        // Validate hash by rebuilding the hash from the parameters and the URL and see if this matches against the stored password_reset_token
310
        $hash = GeneralUtility::hmac($token . '|' . (string)$expirationTimestamp . '|' . $user['email'] . '|' . (string)$user['uid'], 'password-reset');
311
        if (!$this->getHasher()->checkPassword($hash, $user['password_reset_token'] ?? '')) {
312
            return null;
313
        }
314
        return $user;
315
    }
316
317
    /**
318
     * Update the password in the database if the password matches and the token is valid.
319
     *
320
     * @param ServerRequestInterface $request
321
     * @param Context $context current context
322
     * @return bool whether the password was resetted or not
323
     */
324
    public function resetPassword(ServerRequestInterface $request, Context $context): bool
325
    {
326
        $expirationTimestamp = (int)($request->getQueryParams()['e'] ?? '');
327
        $identityHash = (string)($request->getQueryParams()['i'] ?? '');
328
        $token = (string)($request->getQueryParams()['t'] ?? '');
329
        $newPassword = (string)$request->getParsedBody()['password'];
330
        $newPasswordRepeat = (string)$request->getParsedBody()['passwordrepeat'];
331
        if (strlen($newPassword) < 8 || $newPassword !== $newPasswordRepeat) {
332
            $this->logger->debug('Password reset not possible due to weak password');
333
            return false;
334
        }
335
        $user = $this->findValidUserForToken($token, $identityHash, $expirationTimestamp);
336
        if ($user === null) {
337
            $this->logger->warning('Password reset not possible. Valid user for token not found.');
338
            return false;
339
        }
340
        $userId = (int)$user['uid'];
341
342
        GeneralUtility::makeInstance(ConnectionPool::class)
343
            ->getConnectionForTable('be_users')
344
            ->update('be_users', ['password_reset_token' => '', 'password' => $this->getHasher()->getHashedPassword($newPassword)], ['uid' => $userId]);
345
346
        $this->logger->info('Password reset successful for user ' . $userId);
347
        $this->log(
348
            'Password reset successful for user %s',
349
            SystemLogLoginAction::PASSWORD_RESET_ACCOMPLISHED,
350
            SystemLogErrorClassification::SECURITY_NOTICE,
351
            $userId,
352
            [
353
                'email' => $user['email'],
354
                'user' => $userId
355
            ],
356
            NormalizedParams::createFromRequest($request)->getRemoteAddress(),
357
            $context
358
        );
359
        return true;
360
    }
361
362
    /**
363
     * The querybuilder for finding the right user - and adds some restrictions:
364
     * - No CLI users
365
     * - No Admin users (with option)
366
     * - No hidden/deleted users
367
     * - Password must be set
368
     * - Username must be set
369
     * - Email address must be set
370
     *
371
     * @return QueryBuilder
372
     */
373
    protected function getPreparedQueryBuilder(): QueryBuilder
374
    {
375
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
376
        $queryBuilder->getRestrictions()
377
            ->removeAll()
378
            ->add(GeneralUtility::makeInstance(RootLevelRestriction::class))
379
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
380
            ->add(GeneralUtility::makeInstance(StartTimeRestriction::class))
381
            ->add(GeneralUtility::makeInstance(EndTimeRestriction::class))
382
            ->add(GeneralUtility::makeInstance(HiddenRestriction::class));
383
        $queryBuilder->where(
384
            $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('')),
385
            $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('_cli_')),
386
            $queryBuilder->expr()->neq('password', $queryBuilder->createNamedParameter('')),
387
            $queryBuilder->expr()->neq('email', $queryBuilder->createNamedParameter(''))
388
        );
389
        if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] ?? false)) {
390
            $queryBuilder->andWhere(
391
                $queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
392
            );
393
        }
394
        return $queryBuilder;
395
    }
396
397
    protected function getHasher(): PasswordHashInterface
398
    {
399
        return GeneralUtility::makeInstance(PasswordHashFactory::class)->getDefaultHashInstance('BE');
400
    }
401
402
    /**
403
     * Adds an entry to "sys_log", also used to track the maximum allowed attempts.
404
     *
405
     * @param string $message the information / message in english
406
     * @param int $action see SystemLogLoginAction
407
     * @param int $error see SystemLogErrorClassification
408
     * @param int $userId
409
     * @param array $data additional information, used for the message
410
     * @param $ipAddress
411
     * @param Context $context
412
     */
413
    protected function log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, Context $context): void
414
    {
415
        $fields = [
416
            'userid' => $userId,
417
            'type' => SystemLogType::LOGIN,
418
            'action' => $action,
419
            'error' => $error,
420
            'details_nr' => 1,
421
            'details' => $message,
422
            'log_data' => serialize($data),
423
            'tablename' => 'be_users',
424
            'recuid' => $userId,
425
            'IP' => (string)$ipAddress,
426
            'tstamp' => $context->getAspect('date')->get('timestamp'),
427
            'event_pid' => 0,
428
            'NEWid' => '',
429
            'workspace' => 0
430
        ];
431
432
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_log');
433
        $connection->insert(
434
            'sys_log',
435
            $fields,
436
            [
437
                \PDO::PARAM_INT,
438
                \PDO::PARAM_INT,
439
                \PDO::PARAM_INT,
440
                \PDO::PARAM_INT,
441
                \PDO::PARAM_INT,
442
                \PDO::PARAM_STR,
443
                \PDO::PARAM_STR,
444
                \PDO::PARAM_STR,
445
                \PDO::PARAM_INT,
446
                \PDO::PARAM_STR,
447
                \PDO::PARAM_INT,
448
                \PDO::PARAM_INT,
449
                \PDO::PARAM_STR,
450
                \PDO::PARAM_STR,
451
            ]
452
        );
453
    }
454
455
    /**
456
     * Checks if an email reset link has been requested more than 3 times in the last 30mins.
457
     * If a password was successfully reset more than three times in 30 minutes, it would still fail.
458
     *
459
     * @param Context $context
460
     * @param string $email
461
     * @return bool
462
     */
463
    protected function hasExceededMaximumAttemptsForReset(Context $context, string $email): bool
464
    {
465
        $now = $context->getAspect('date')->getDateTime();
466
        $numberOfAttempts = $this->getNumberOfInitiatedResetsForEmail($now->modify(self::MAXIMUM_RESET_ATTEMPTS_SINCE), $email);
467
        return $numberOfAttempts > self::MAXIMUM_RESET_ATTEMPTS;
468
    }
469
470
    /**
471
     * SQL query to find the amount of initiated resets from a given time.
472
     *
473
     * @param \DateTimeInterface $since
474
     * @param string $email
475
     * @return int
476
     */
477
    protected function getNumberOfInitiatedResetsForEmail(\DateTimeInterface $since, string $email): int
478
    {
479
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log');
480
        return (int)$queryBuilder
481
            ->count('uid')
482
            ->from('sys_log')
483
            ->where(
484
                $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(SystemLogType::LOGIN)),
485
                $queryBuilder->expr()->eq('action', $queryBuilder->createNamedParameter(SystemLogLoginAction::PASSWORD_RESET_REQUEST)),
486
                $queryBuilder->expr()->eq('log_data', $queryBuilder->createNamedParameter(serialize(['email' => $email]))),
487
                $queryBuilder->expr()->gte('tstamp', $queryBuilder->createNamedParameter($since->getTimestamp(), \PDO::PARAM_INT))
488
            )
489
            ->execute()
490
            ->fetchColumn(0);
491
    }
492
}
493