Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/matomo-org/matomo.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordiosmosis <benaka@piwik.pro>2014-09-27 09:50:02 +0400
committerdiosmosis <benaka@piwik.pro>2014-09-27 14:27:44 +0400
commit13f7e2ca13264b615aa0233faa9ca589c83fedb7 (patch)
treea9ae5c66c5189202fe6953821b747c05ad6974f0 /plugins/Login/PasswordResetter.php
parent6a959a5566f25e828f07ac5d588023931a32ba97 (diff)
Refactor Login controller and auth to be more modular and easier to extend/override. Added extra method to Auth interface and documented Auth interface completely. Created new PasswordResetter class to contain password reset logic.
Diffstat (limited to 'plugins/Login/PasswordResetter.php')
-rw-r--r--plugins/Login/PasswordResetter.php476
1 files changed, 476 insertions, 0 deletions
diff --git a/plugins/Login/PasswordResetter.php b/plugins/Login/PasswordResetter.php
new file mode 100644
index 0000000000..efd91e0ad2
--- /dev/null
+++ b/plugins/Login/PasswordResetter.php
@@ -0,0 +1,476 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+namespace Piwik\Plugins\Login;
+
+use Exception;
+use Piwik\Common;
+use Piwik\Config;
+use Piwik\IP;
+use Piwik\Mail;
+use Piwik\Option;
+use Piwik\Piwik;
+use Piwik\Plugins\UsersManager\UsersManager;
+use Piwik\Plugins\UsersManager\API as UsersManagerAPI;
+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. He/she enters 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 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 name to use in the From: part of the confirm password reset email.
+ *
+ * Defaults to the `[General] login_password_recovery_email_name` INI config option.
+ *
+ * @var string
+ */
+ private $emailFromName;
+
+ /**
+ * The from email to use in the confirm password reset email.
+ *
+ * Deafults to the `[General] login_password_recovery_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
+ */
+ public function __construct($usersManagerApi = null, $confirmPasswordModule = null, $confirmPasswordAction = null,
+ $emailFromName = null, $emailFromAddress = null)
+ {
+ if (empty($usersManagerApi)) {
+ $usersManagerApi = UsersManagerAPI::getInstance();
+ }
+ $this->usersManagerApi = $usersManagerApi;
+
+ if (!empty($confirmPasswordModule)) {
+ $this->confirmPasswordModule = $confirmPasswordModule;
+ }
+
+ if (!empty($confirmPasswordAction)) {
+ $this->confirmPasswordAction = $confirmPasswordAction;
+ }
+
+ if (empty($emailFromName)) {
+ $emailFromName = Config::getInstance()->General['login_password_recovery_email_name'];
+ }
+ $this->emailFromName = $emailFromName;
+
+ if (empty($emailFromAddress)) {
+ $emailFromAddress = Config::getInstance()->General['login_password_recovery_email_address'];
+ }
+ $this->emailFromAddress = $emailFromAddress;
+ }
+
+ /**
+ * 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 new Exception(Piwik::translate('Login_InvalidUsernameEmail'));
+ }
+
+ $login = $user['login'];
+
+ $this->savePasswordResetInfo($login, $newPassword);
+
+ // ... send email with confirmation link
+ try {
+ $this->sendEmailConfirmationLink($user);
+ } catch (Exception $ex) {
+ // remove password reset info
+ $this->removePasswordResetInfo($login);
+
+ throw new Exception($ex->getMessage() . Piwik::translate('Login_ContactAdmin'));
+ }
+ }
+
+ /**
+ * 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 $resetToken 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 confirmNewPassword($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
+ $resetPassword = $this->getPasswordToResetTo($login);
+ if ($resetPassword === false
+ || !$this->isTokenValid($resetToken, $user)
+ ) {
+ throw new Exception(Piwik::translate('Login_InvalidOrExpiredToken'));
+ }
+
+ // check that the stored password hash is valid (sanity check)
+ $this->checkPasswordHash($resetPassword);
+
+ // reset password of user
+ $this->usersManagerApi->updateUser(
+ $user['login'], $resetPassword, $email = false, $alias = false, $isPasswordHashed = true);
+ }
+
+ /**
+ * 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.
+ * @return bool true if valid, false otherwise.
+ */
+ public function isTokenValid($token, $user)
+ {
+ $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, $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 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, $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 = strftime('%Y%m%d%H', $expiryTimestamp);
+ $token = $this->generateSecureHash(
+ $expiry . $user['login'] . $user['email'],
+ $user['password']
+ );
+ return $token;
+ }
+
+ /**
+ * 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. By default generates an MD5 hash.
+ *
+ * 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($newPassword)
+ {
+ UsersManager::checkPassword($newPassword);
+ }
+
+ /**
+ * Returns user information based on a login or email.
+ *
+ * Derived classes can override this method to provide custom user querying logic.
+ *
+ * @param string $loginMail user login or email address
+ * @return array `array("login" => '...', "email" => '...', "password" => '...')` or null, if user not found.
+ */
+ protected function getUserInformation($loginOrMail)
+ {
+ $usersManager = $this->usersManagerApi;
+ return Piwik::doAsSuperUser(function () use ($loginOrMail, $usersManager) {
+ $user = null;
+ if ($usersManager->userExists($loginOrMail)) {
+ $user = $usersManager->getUser($loginOrMail);
+ } else if ($usersManager->userEmailExists($loginOrMail)) {
+ $user = $usersManager->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 $password The password to check.
+ * @throws Exception if the password is not 32 bytes long.
+ */
+ protected function checkPasswordHash($password)
+ {
+ if (strlen($password) != 32) {
+ throw new Exception(Piwik::translate('Login_ExceptionPasswordMD5HashExpected'));
+ }
+ }
+
+ /**
+ * Sends email confirmation link for a password reset request.
+ *
+ * @param array $user User info for the requested password reset.
+ */
+ private function sendEmailConfirmationLink($user)
+ {
+ $login = $user['login'];
+ $email = $user['email'];
+
+ // construct a password reset token from user information
+ $resetToken = $this->generatePasswordResetToken($user);
+
+ $confirmPasswordModule = $this->confirmPasswordModule;
+ $confirmPasswordAction = $this->confirmPasswordAction;
+
+ $ip = IP::getIpFromHeader();
+ $url = Url::getCurrentUrlWithoutQueryString()
+ . "?module=$confirmPasswordModule&action=$confirmPasswordAction&login=" . urlencode($login)
+ . "&resetToken=" . urlencode($resetToken);
+
+ // send email with new password
+ $mail = new Mail();
+ $mail->addTo($email, $login);
+ $mail->setSubject(Piwik::translate('Login_MailTopicPasswordChange'));
+ $bodyText = str_replace(
+ '\n',
+ "\n",
+ sprintf(Piwik::translate('Login_MailPasswordChangeBody'), $login, $ip, $url)
+ ) . "\n";
+ $mail->setBodyText($bodyText);
+
+ $mail->setFrom($this->emailFromAddress, $this->emailFromName);
+
+ $replytoEmailName = Config::getInstance()->General['login_password_recovery_replyto_email_name'];
+ $replytoEmailAddress = Config::getInstance()->General['login_password_recovery_replyto_email_address'];
+ $mail->setReplyTo($replytoEmailAddress, $replytoEmailName);
+
+ @$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.
+ */
+ private function savePasswordResetInfo($login, $newPassword)
+ {
+ $optionName = $this->getPasswordResetInfoOptionName($login);
+ $optionData = UsersManager::getPasswordHash($newPassword);
+
+ Option::set($optionName, $optionData);
+ }
+
+ /**
+ * Gets password hash stored in password reset info.
+ *
+ * @param string $login The user login to check for.
+ * @return string|false The hashed password or false if no reset info exists.
+ */
+ private function getPasswordToResetTo($login)
+ {
+ $optionName = self::getPasswordResetInfoOptionName($login);
+ return Option::get($optionName);
+ }
+
+ /**
+ * 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';
+ }
+} \ No newline at end of file