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