1<?php
2/**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 */
8namespace Piwik\Plugins\Login;
9
10use Exception;
11use Piwik\Access;
12use Piwik\Auth\Password;
13use Piwik\Common;
14use Piwik\Config;
15use Piwik\IP;
16use Piwik\Mail;
17use Piwik\Option;
18use Piwik\Piwik;
19use Piwik\Plugins\UsersManager\Model;
20use Piwik\Plugins\UsersManager\UsersManager;
21use Piwik\Plugins\UsersManager\API as UsersManagerAPI;
22use Piwik\Plugins\UsersManager\UserUpdater;
23use Piwik\SettingsPiwik;
24use Piwik\Url;
25
26/**
27 * Contains the logic for different parts of the password reset process.
28 *
29 * The process to reset a password is as follows:
30 *
31 * 1. The user chooses to reset a password. They enter a new password
32 *    and submits it to Piwik.
33 * 2. PasswordResetter will store the hash of the password in the Option table.
34 *    This is done by {@link initiatePasswordResetProcess()}.
35 * 3. PasswordResetter will generate a reset token and email the user a link
36 *    to confirm that they requested a password reset. (This way an attacker
37 *    cannot reset a user's password if they do not have control of the user's
38 *    email address.)
39 * 4. The user opens the email and clicks on the link. The link leads to
40 *    a controller action that finishes the password reset process.
41 * 5. When the link is clicked, PasswordResetter will update the user's password
42 *    and remove the Option stored earlier. This is accomplished by
43 *    {@link confirmNewPassword()}.
44 *
45 * Note: this class does not contain any controller logic so it won't directly
46 * handle certain requests. Controllers should call the appropriate methods.
47 *
48 * ## Reset Tokens
49 *
50 * Reset tokens are hashes that are unique for each user and are associated with
51 * an expiry timestamp in the future. see the {@link generatePasswordResetToken()}
52 * and {@link isTokenValid()} methods for more info.
53 *
54 * By default, reset tokens will expire after 24 hours.
55 *
56 * ## Overriding
57 *
58 * Plugins that want to tweak the password reset process can derive from this
59 * class. They can override certain methods (read documentation for individual
60 * methods to see why and how you might want to), but for the overriding to
61 * have effect, it must be used by the Login controller.
62 */
63class PasswordResetter
64{
65    /**
66     * @var Password
67     */
68    protected $passwordHelper;
69
70    /**
71     * @var UsersManagerAPI
72     */
73    protected $usersManagerApi;
74
75    /**
76     * The module to link to in the confirm password reset email.
77     *
78     * @var string
79     */
80    private $confirmPasswordModule = "Login";
81
82    /**
83     * The action to link to in the confirm password reset email.
84     *
85     * @var string
86     */
87    private $confirmPasswordAction = "confirmResetPassword";
88
89    /**
90     * The name to use in the From: part of the confirm password reset email.
91     *
92     * Defaults to the `[General] noreply_email_name` INI config option.
93     *
94     * @var string
95     */
96    private $emailFromName;
97
98    /**
99     * The from email to use in the confirm password reset email.
100     *
101     * Defaults to the `[General] noreply_email_address` INI config option.
102     *
103     * @var
104     */
105    private $emailFromAddress;
106
107    /**
108     * Constructor.
109     *
110     * @param UsersManagerAPI|null $usersManagerApi
111     * @param string|null $confirmPasswordModule
112     * @param string|null $confirmPasswordAction
113     * @param string|null $emailFromName
114     * @param string|null $emailFromAddress
115     * @param Password $passwordHelper
116     */
117    public function __construct($usersManagerApi = null, $confirmPasswordModule = null, $confirmPasswordAction = null,
118                                $emailFromName = null, $emailFromAddress = null, $passwordHelper = null)
119    {
120        if (empty($usersManagerApi)) {
121            $usersManagerApi = UsersManagerAPI::getInstance();
122        }
123        $this->usersManagerApi = $usersManagerApi;
124
125        if (!empty($confirmPasswordModule)) {
126            $this->confirmPasswordModule = $confirmPasswordModule;
127        }
128
129        if (!empty($confirmPasswordAction)) {
130            $this->confirmPasswordAction = $confirmPasswordAction;
131        }
132
133        $this->emailFromName = $emailFromName;
134        $this->emailFromAddress = $emailFromAddress;
135
136        if (empty($passwordHelper)) {
137            $passwordHelper = new Password();
138        }
139        $this->passwordHelper = $passwordHelper;
140    }
141
142    /**
143     * Initiates the password reset process. This method will save the password reset
144     * information as an {@link Option} and send an email with the reset confirmation
145     * link to the user whose password is being reset.
146     *
147     * The email confirmation link will contain the generated reset token.
148     *
149     * @param string $loginOrEmail The user's login or email address.
150     * @param string $newPassword The un-hashed/unencrypted password.
151     * @throws Exception if $loginOrEmail does not correspond with a non-anonymous user,
152     *                   if the new password does not pass UserManager's password
153     *                   complexity requirements
154     *                   or if sending an email fails in some way
155     */
156    public function initiatePasswordResetProcess($loginOrEmail, $newPassword)
157    {
158        $this->checkNewPassword($newPassword);
159
160        // 'anonymous' has no password and cannot be reset
161        if ($loginOrEmail === 'anonymous') {
162            throw new Exception(Piwik::translate('Login_InvalidUsernameEmail'));
163        }
164
165        // get the user's login
166        $user = $this->getUserInformation($loginOrEmail);
167        if ($user === null) {
168            throw new Exception(Piwik::translate('Login_InvalidUsernameEmail'));
169        }
170
171        $login = $user['login'];
172
173        $keySuffix = time() . Common::getRandomString($length = 32);
174        $this->savePasswordResetInfo($login, $newPassword, $keySuffix);
175
176        // ... send email with confirmation link
177        try {
178            $this->sendEmailConfirmationLink($user, $keySuffix);
179        } catch (Exception $ex) {
180            // remove password reset info
181            $this->removePasswordResetInfo($login);
182
183            throw new Exception($ex->getMessage() . Piwik::translate('Login_ContactAdmin'));
184        }
185    }
186
187    public function checkValidConfirmPasswordToken($login, $resetToken)
188    {
189        // get password reset info & user info
190        $user = self::getUserInformation($login);
191        if ($user === null) {
192            throw new Exception(Piwik::translate('Login_InvalidUsernameEmail'));
193        }
194
195        // check that the reset token is valid
196        $resetInfo = $this->getPasswordToResetTo($login);
197        if ($resetInfo === false
198            || empty($resetInfo['hash'])
199            || empty($resetInfo['keySuffix'])
200            || !$this->isTokenValid($resetToken, $user, $resetInfo['keySuffix'])
201        ) {
202            throw new Exception(Piwik::translate('Login_InvalidOrExpiredToken'));
203        }
204
205        // check that the stored password hash is valid (sanity check)
206        $resetPassword = $resetInfo['hash'];
207
208        $this->checkPasswordHash($resetPassword);
209
210        return $resetPassword;
211    }
212
213    /**
214     * Confirms a password reset. This should be called after {@link initiatePasswordResetProcess()}
215     * is called.
216     *
217     * This method will get the new password associated with a reset token and set it
218     * as the specified user's password.
219     *
220     * @param string $login The login of the user whose password is being reset.
221     * @param string $passwordHash The generated string token contained in the reset password
222     *                           email.
223     * @throws Exception If there is no user with login '$login', if $resetToken is not a
224     *                   valid token or if the token has expired.
225     */
226    public function setHashedPasswordForLogin($login, $passwordHash)
227    {
228        Access::doAsSuperUser(function () use ($login, $passwordHash) {
229            $userUpdater = new UserUpdater();
230            $userUpdater->updateUserWithoutCurrentPassword(
231                $login,
232                $passwordHash,
233                $email = false,
234                $isPasswordHashed = true
235            );
236        });
237    }
238
239    /**
240     * Returns true if a reset token is valid, false if otherwise. A reset token is valid if
241     * it exists and has not expired.
242     *
243     * @param string $token The reset token to check.
244     * @param array $user The user information returned by the UsersManager API.
245     * @param string $keySuffix The suffix used in generating a token.
246     * @return bool true if valid, false otherwise.
247     */
248    public function isTokenValid($token, $user, $keySuffix)
249    {
250        $now = time();
251
252        // token valid for 24 hrs (give or take, due to the coarse granularity in our strftime format string)
253        for ($i = 0; $i <= 24; $i++) {
254            $generatedToken = $this->generatePasswordResetToken($user, $keySuffix, $now + $i * 60 * 60);
255            if ($generatedToken === $token) {
256                return true;
257            }
258        }
259
260        // fails if token is invalid, expired, password already changed, other user information has changed, ...
261        return false;
262    }
263
264    /**
265     * Generate a password reset token.  Expires in 24 hours from the beginning of the current hour.
266     *
267     * The reset token is generated using a user's email, login and the time when the token expires.
268     *
269     * @param array $user The user information.
270     * @param string $keySuffix The suffix used in generating a token.
271     * @param int|null $expiryTimestamp The expiration timestamp to use or null to generate one from
272     *                                  the current timestamp.
273     * @return string The generated token.
274     */
275    public function generatePasswordResetToken($user, $keySuffix, $expiryTimestamp = null)
276    {
277        /*
278         * Piwik does not store the generated password reset token.
279         * This avoids a database schema change and SQL queries to store, retrieve, and purge (expired) tokens.
280         */
281        if (!$expiryTimestamp) {
282            $expiryTimestamp = $this->getDefaultExpiryTime();
283        }
284
285        $expiry = strftime('%Y%m%d%H', $expiryTimestamp);
286        $token = $this->generateSecureHash(
287            $expiry . $user['login'] . $user['email'] . $user['ts_password_modified'] . $keySuffix,
288            $user['password']
289        );
290        return $token;
291    }
292
293    public function doesResetPasswordHashMatchesPassword($passwordPlain, $passwordHash)
294    {
295        $passwordPlain = UsersManager::getPasswordHash($passwordPlain);
296        return $this->passwordHelper->verify($passwordPlain, $passwordHash);
297    }
298
299    /**
300     * Generates a hash using a hash "identifier" and some data to hash. The hash identifier is
301     * a string that differentiates the hash in some way.
302     *
303     * We can't get the identifier back from a hash but we can tell if a hash is the hash for
304     * a specific identifier by computing a hash for the identifier and comparing with the
305     * first hash.
306     *
307     * @param string $hashIdentifier A unique string that identifies the hash in some way, can,
308     *                               for example, be user information or can contain an expiration date,
309     *                               or whatever.
310     * @param string $data Any data that needs to be hashed securely, ie, a password.
311     * @return string The hash string.
312     */
313    protected function generateSecureHash($hashIdentifier, $data)
314    {
315        // mitigate rainbow table attack
316        $halfDataLen = strlen($data) / 2;
317
318        $stringToHash = $hashIdentifier
319                      . substr($data, 0, $halfDataLen)
320                      . $this->getSalt()
321                      . substr($data, $halfDataLen)
322                      ;
323
324        return $this->hashData($stringToHash);
325    }
326
327    /**
328     * Returns the string salt to use when generating a secure hash. Defaults to the value of
329     * the `[General] salt` INI config option.
330     *
331     * Derived classes can override this to provide a different salt.
332     *
333     * @return string
334     */
335    protected function getSalt()
336    {
337        return SettingsPiwik::getSalt();
338    }
339
340    /**
341     * Hashes a string.
342     *
343     * Derived classes can override this to provide a different hashing implementation.
344     *
345     * @param string $data The data to hash.
346     * @return string
347     */
348    protected function hashData($data)
349    {
350        return Common::hash($data);
351    }
352
353    /**
354     * Returns an expiration time from the current time. By default it will be one day (24 hrs) from
355     * now.
356     *
357     * Derived classes can override this to provide a different default expiration time
358     * generation implementation.
359     *
360     * @return int
361     */
362    protected function getDefaultExpiryTime()
363    {
364        return time() + 24 * 60 * 60; /* +24 hrs */
365    }
366
367    /**
368     * Checks the reset password's complexity. Will use UsersManager's requirements for user passwords.
369     *
370     * Derived classes can override this method to provide fewer or additional checks.
371     *
372     * @param string $newPassword The password to check.
373     * @throws Exception if $newPassword is inferior in some way.
374     */
375    protected function checkNewPassword($newPassword)
376    {
377        UsersManager::checkPassword($newPassword);
378    }
379
380    /**
381     * Returns user information based on a login or email.
382     *
383     * Derived classes can override this method to provide custom user querying logic.
384     *
385     * @param string $loginMail user login or email address
386     * @return array `array("login" => '...', "email" => '...', "password" => '...')` or null, if user not found.
387     */
388    protected function getUserInformation($loginOrMail)
389    {
390        $userModel = new Model();
391
392        $user = null;
393        if ($userModel->userExists($loginOrMail)) {
394            $user = $userModel->getUser($loginOrMail);
395        } else if ($userModel->userEmailExists($loginOrMail)) {
396            $user = $userModel->getUserByEmail($loginOrMail);
397        }
398        return $user;
399    }
400
401    /**
402     * Checks the password hash that was retrieved from the Option table. Used as a sanity check
403     * when finishing the reset password process. If a password is obviously malformed, changing
404     * a user's password to it will keep the user from being able to login again.
405     *
406     * Derived classes can override this method to provide fewer or more checks.
407     *
408     * @param string $passwordHash The password hash to check.
409     * @throws Exception if the password hash length is incorrect.
410     */
411    protected function checkPasswordHash($passwordHash)
412    {
413        $hashInfo = $this->passwordHelper->info($passwordHash);
414
415        if (!isset($hashInfo['algo']) || 0 >= $hashInfo['algo']) {
416            throw new Exception(Piwik::translate('Login_ExceptionPasswordMD5HashExpected'));
417        }
418    }
419
420    /**
421     * Sends email confirmation link for a password reset request.
422     *
423     * @param array $user User info for the requested password reset.
424     * @param string $keySuffix The suffix used in generating a token.
425     */
426    private function sendEmailConfirmationLink($user, $keySuffix)
427    {
428        $login = $user['login'];
429        $email = $user['email'];
430
431        // construct a password reset token from user information
432        $resetToken = $this->generatePasswordResetToken($user, $keySuffix);
433
434        $confirmPasswordModule = $this->confirmPasswordModule;
435        $confirmPasswordAction = $this->confirmPasswordAction;
436
437        $ip = IP::getIpFromHeader();
438        $url = Url::getCurrentUrlWithoutQueryString()
439            . "?module=$confirmPasswordModule&action=$confirmPasswordAction&login=" . urlencode($login)
440            . "&resetToken=" . urlencode($resetToken);
441
442        // send email with new password
443        $mail = new Mail();
444        $mail->addTo($email, $login);
445        $mail->setSubject(Piwik::translate('Login_MailTopicPasswordChange'));
446        $bodyText = '<p>' . str_replace(
447                "\n\n",
448                "</p><p>",
449                Piwik::translate('Login_MailPasswordChangeBody2', [Common::sanitizeInputValue($login), Common::sanitizeInputValue($ip), Common::sanitizeInputValue($url)])
450            ) . "</p>";
451        $mail->setWrappedHtmlBody($bodyText);
452
453        if ($this->emailFromAddress || $this->emailFromName) {
454            $mail->setFrom($this->emailFromAddress, $this->emailFromName);
455        } else {
456            $mail->setDefaultFromPiwik();
457        }
458
459        $replytoEmailName = Config::getInstance()->General['login_password_recovery_replyto_email_name'];
460        $replytoEmailAddress = Config::getInstance()->General['login_password_recovery_replyto_email_address'];
461        $mail->addReplyTo($replytoEmailAddress, $replytoEmailName);
462
463        @$mail->send();
464    }
465
466    /**
467     * Stores password reset info for a specific login.
468     *
469     * @param string $login The user login for whom a password change was requested.
470     * @param string $newPassword The new password to set.
471     * @param string $keySuffix The suffix used in generating a token.
472     *
473     * @throws Exception if a password reset was already requested within one hour
474     */
475    private function savePasswordResetInfo($login, $newPassword, $keySuffix)
476    {
477        $optionName = self::getPasswordResetInfoOptionName($login);
478
479        $existingResetInfo = Option::get($optionName);
480
481        $time = time();
482        $count = 0;
483
484        if ($existingResetInfo) {
485            $existingResetInfo = json_decode($existingResetInfo, true);
486
487            if (isset($existingResetInfo['timestamp']) && $existingResetInfo['timestamp'] > time()-3600) {
488                $time = $existingResetInfo['timestamp'];
489                $count = !empty($existingResetInfo['requests']) ? $existingResetInfo['requests'] : $count;
490
491                if(isset($existingResetInfo['requests']) && $existingResetInfo['requests'] > 2) {
492                    throw new Exception(Piwik::translate('Login_PasswordResetAlreadySent'));
493                }
494            }
495        }
496
497
498        $optionData = [
499            'hash' => $this->passwordHelper->hash(UsersManager::getPasswordHash($newPassword)),
500            'keySuffix' => $keySuffix,
501            'timestamp' => $time,
502            'requests' => $count+1
503        ];
504        $optionData = json_encode($optionData);
505
506        Option::set($optionName, $optionData);
507    }
508
509    /**
510     * Gets password hash stored in password reset info.
511     *
512     * @param string $login The user login to check for.
513     * @return string|false The hashed password or false if no reset info exists.
514     */
515    private function getPasswordToResetTo($login)
516    {
517        $optionName = self::getPasswordResetInfoOptionName($login);
518        $optionValue = Option::get($optionName);
519        $optionValue = json_decode($optionValue, $isAssoc = true);
520        return $optionValue;
521    }
522
523    /**
524     * Removes stored password reset info if it exists.
525     *
526     * @param string $login The user login to check for.
527     */
528    public function removePasswordResetInfo($login)
529    {
530        $optionName = self::getPasswordResetInfoOptionName($login);
531        Option::delete($optionName);
532    }
533
534    /**
535     * Gets the option name for the option that will store a user's password change
536     * request.
537     *
538     * @param string $login The user login for whom a password change was requested.
539     * @return string
540     */
541    public static function getPasswordResetInfoOptionName($login)
542    {
543        return $login . '_reset_password_info';
544    }
545}
546