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