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:
-rw-r--r--core/Auth.php70
-rw-r--r--core/Piwik.php30
-rw-r--r--plugins/Login/Auth.php37
-rw-r--r--plugins/Login/Controller.php249
-rw-r--r--plugins/Login/Login.php48
-rw-r--r--plugins/Login/PasswordResetter.php476
6 files changed, 635 insertions, 275 deletions
diff --git a/core/Auth.php b/core/Auth.php
index 6ae99687b3..5243290a81 100644
--- a/core/Auth.php
+++ b/core/Auth.php
@@ -9,49 +9,87 @@
namespace Piwik;
+use Exception;
+
/**
- * Base for authentication modules
+ * Base for authentication implementations. Plugins that provide Auth implementations
+ * must provide a class that implements this interface. Additionally, an instance
+ * of that class must be set in the {@link \Piwik\Registry} class with the 'auth'
+ * key during the {@link Request.initAuthenticationObject} event.
+ *
+ * Authentication implementations must support authentication via username and
+ * clear-text password and authentication via username and token auth. They can
+ * additionally support authentication via username and an MD5 hash of a password. If
+ * they don't support it, then formless authentication will fail.
+ *
+ * Derived implementations should favor authenticating by password over authenticating
+ * by token auth. That is to say, if a token auth and a password are set, password
+ * authentication should be used.
+ *
+ * @api
*/
interface Auth
{
/**
- * Authentication module's name, e.g., "Login"
+ * Must return the Authentication module's name, e.g., `"Login"`.
*
* @return string
*/
public function getName();
/**
- * Authenticates user
+ * Sets the authentication token to authenticate with.
*
- * @return AuthResult
+ * @param string $token_auth authentication token
*/
- public function authenticate();
+ public function setTokenAuth($token_auth);
/**
- * Authenticates the user and initializes the session.
+ * Sets the login name to authenticate with.
+ *
+ * @param string $login The username.
*/
- public function initSession($login, $md5Password, $rememberMe);
+ public function setLogin($login);
/**
- * Accessor to set authentication token. If set, you can authenticate the tokenAuth by calling the authenticate()
- * method afterwards.
+ * Sets the password to authenticate with.
*
- * @param string $token_auth authentication token
+ * @param string $password Password (not hashed).
*/
- public function setTokenAuth($token_auth);
+ public function setPassword($password);
/**
- * Accessor to set login name
+ * Sets the hash of the password to authenticate with. The hash will be an MD5 hash.
*
- * @param string $login user login
+ * @param string $passwordHash The hashed password.
+ * @throws Exception if authentication by hashed password is not supported.
*/
- public function setLogin($login);
+ public function setPasswordHash($passwordHash);
+
+ /**
+ * Authenticates a user using the login and password set using the setters. Can also authenticate
+ * via token auth if one is set and no password is set.
+ *
+ * @return AuthResult
+ */
+ public function authenticate();
+
+ /**
+ * Authenticates the user using login and password and initializes an authenticated session.
+ *
+ * @param bool $rememberMe Whether the user should be remembered by setting a client side cookie
+ * or not.
+ *
+ * TODO: maybe this logic should be handled by Login\Controller?
+ */
+ public function initSession($rememberMe);
}
/**
- * Authentication result
+ * Authentication result. This is what is returned by authentication attempts using {@link Auth}
+ * implementations.
*
+ * @api
*/
class AuthResult
{
@@ -144,4 +182,4 @@ class AuthResult
{
return $this->code > self::FAILURE;
}
-}
+} \ No newline at end of file
diff --git a/core/Piwik.php b/core/Piwik.php
index e52f329404..c00d5e42e9 100644
--- a/core/Piwik.php
+++ b/core/Piwik.php
@@ -861,4 +861,32 @@ class Piwik
}
return $options;
}
-}
+
+ /**
+ * Executes a callback with superuser privileges, making sure those privileges are rescinded
+ * before this method exits. Privileges will be rescinded even if an exception is thrown.
+ *
+ * @param callback $function The callback to execute. Should accept no arguments.
+ * @return mixed The result of `$function`.
+ * @throws Exception rethrows any exceptions thrown by `$function`.
+ * @api
+ */
+ public static function doAsSuperUser($function)
+ {
+ $isSuperUser = self::hasUserSuperUserAccess();
+
+ self::setUserHasSuperUserAccess();
+
+ try {
+ $result = $function();
+ } catch (Exception $ex) {
+ self::setUserHasSuperUserAccess($isSuperUser);
+
+ throw $ex;
+ }
+
+ self::setUserHasSuperUserAccess($isSuperUser);
+
+ return $result;
+ }
+} \ No newline at end of file
diff --git a/plugins/Login/Auth.php b/plugins/Login/Auth.php
index 55f31757c1..d21a256aa9 100644
--- a/plugins/Login/Auth.php
+++ b/plugins/Login/Auth.php
@@ -26,6 +26,7 @@ class Auth implements \Piwik\Auth
{
protected $login = null;
protected $token_auth = null;
+ protected $md5Password = null;
/**
* Authentication module's name, e.g., "Login"
@@ -74,16 +75,16 @@ class Auth implements \Piwik\Auth
/**
* Authenticates the user and initializes the session.
*/
- public function initSession($login, $md5Password, $rememberMe)
+ public function initSession($rememberMe)
{
$this->regenerateSessionId();
- $authResult = $this->doAuthenticateSession($login, $md5Password);
+ $authResult = $this->doAuthenticateSession($this->login, $this->md5Password);
if (!$authResult->wasAuthenticationSuccessful()) {
$this->processFailedSession($rememberMe);
} else {
- $this->processSuccessfulSession($login, $authResult->getTokenAuth(), $rememberMe);
+ $this->processSuccessfulSession($this->login, $authResult->getTokenAuth(), $rememberMe);
}
/**
@@ -132,6 +133,31 @@ class Auth implements \Piwik\Auth
}
/**
+ * Sets the password to authenticate with.
+ *
+ * @param string $password
+ */
+ public function setPassword($password)
+ {
+ $this->md5Password = md5($password);
+ }
+
+ /**
+ * Sets the password hash to use when authentication.
+ *
+ * @param string $passwordHash The password hash.
+ * @throws Exception if $passwordHash does not have 32 characters in it.
+ */
+ public function setPasswordHash($passwordHash)
+ {
+ if (strlen($passwordHash) != 32) {
+ throw new Exception("Invalid hash: incorrect length " . strlen($passwordHash));
+ }
+
+ $this->md5Password = $passwordHash;
+ }
+
+ /**
* @param $login
* @param $md5Password
* @return AuthResult
@@ -238,13 +264,10 @@ class Auth implements \Piwik\Auth
$cookie->setSecure(ProxyHttp::isHttps());
$cookie->setHttpOnly(true);
$cookie->save();
-
- // remove password reset entry if it exists
- Login::removePasswordResetInfo($login);
}
protected function regenerateSessionId()
{
Session::regenerateId();
}
-}
+} \ No newline at end of file
diff --git a/plugins/Login/Controller.php b/plugins/Login/Controller.php
index cce38665a6..eefec32a90 100644
--- a/plugins/Login/Controller.php
+++ b/plugins/Login/Controller.php
@@ -9,18 +9,14 @@
namespace Piwik\Plugins\Login;
use Exception;
+use Piwik\Auth as AuthInterface;
use Piwik\Common;
use Piwik\Config;
use Piwik\Cookie;
-use Piwik\IP;
-use Piwik\Mail;
use Piwik\Nonce;
use Piwik\Piwik;
-use Piwik\Plugins\UsersManager\API;
-use Piwik\Plugins\UsersManager\UsersManager;
use Piwik\QuickForm2;
use Piwik\Session;
-use Piwik\SettingsPiwik;
use Piwik\Url;
use Piwik\View;
@@ -33,21 +29,34 @@ require_once PIWIK_INCLUDE_PATH . '/core/Config.php';
class Controller extends \Piwik\Plugin\Controller
{
/**
- * Generate hash on user info and password
- *
- * @param string $userInfo User name, email, etc
- * @param string $password
- * @return string
+ * @var PasswordResetter
+ */
+ private $passwordResetter;
+
+ /**
+ * @var Auth
*/
- private function generateHash($userInfo, $password)
+ private $auth;
+
+ /**
+ * Constructor.
+ *
+ * @param PasswordResetter $passwordResetter
+ * @param AuthInterface $auth
+\ */
+ public function __construct($passwordResetter = null, $auth = null)
{
- // mitigate rainbow table attack
- $passwordLen = strlen($password) / 2;
- $hash = Common::hash(
- $userInfo . substr($password, 0, $passwordLen)
- . SettingsPiwik::getSalt() . substr($password, $passwordLen)
- );
- return $hash;
+ parent::__construct();
+
+ if (empty($passwordResetter)) {
+ $passwordResetter = new PasswordResetter();
+ }
+ $this->passwordResetter = $passwordResetter;
+
+ if (empty($auth)) {
+ $auth = \Piwik\Registry::get('auth');
+ }
+ $this->auth = $auth;
}
/**
@@ -78,9 +87,8 @@ class Controller extends \Piwik\Plugin\Controller
$login = $form->getSubmitValue('form_login');
$password = $form->getSubmitValue('form_password');
$rememberMe = $form->getSubmitValue('form_rememberme') == '1';
- $md5Password = md5($password);
try {
- $this->authenticateAndRedirect($login, $md5Password, $rememberMe);
+ $this->authenticateAndRedirect($login, $password, $rememberMe);
} catch (Exception $e) {
$messageNoAccess = $e->getMessage();
}
@@ -123,7 +131,6 @@ class Controller extends \Piwik\Plugin\Controller
function logme()
{
$password = Common::getRequestVar('password', null, 'string');
- $this->checkPasswordHash($password);
$login = Common::getRequestVar('login', null, 'string');
if (Piwik::hasTheUserSuperUserAccess($login)) {
@@ -139,25 +146,34 @@ class Controller extends \Piwik\Plugin\Controller
$urlToRedirect = Common::getRequestVar('url', $currentUrl, 'string');
$urlToRedirect = Common::unsanitizeInputValue($urlToRedirect);
- $this->authenticateAndRedirect($login, $password, false, $urlToRedirect);
+ $this->authenticateAndRedirect($login, $password, false, $urlToRedirect, $passwordHashed = true);
}
/**
* Authenticate user and password. Redirect if successful.
*
* @param string $login user name
- * @param string $md5Password md5 hash of password
+ * @param string $password md5 password
* @param bool $rememberMe Remember me?
* @param string $urlToRedirect URL to redirect to, if successfully authenticated
* @return string failure message if unable to authenticate
*/
- protected function authenticateAndRedirect($login, $md5Password, $rememberMe, $urlToRedirect = false)
+ protected function authenticateAndRedirect($login, $password, $rememberMe, $urlToRedirect = false, $passwordHashed = false)
{
Nonce::discardNonce('Login.login');
- \Piwik\Registry::get('auth')->initSession($login, $md5Password, $rememberMe);
+ $this->auth->setLogin($login);
+ if ($passwordHashed === false) {
+ $this->auth->setPassword($password);
+ } else {
+ $this->auth->setPasswordHash($password);
+ }
+ $this->auth->initSession($rememberMe);
+
+ // remove password reset entry if it exists
+ $this->passwordResetter->removePasswordResetInfo($login);
- if(empty($urlToRedirect)) {
+ if (empty($urlToRedirect)) {
$urlToRedirect = Url::getCurrentUrlWithoutQueryString();
}
@@ -217,82 +233,16 @@ class Controller extends \Piwik\Plugin\Controller
$loginMail = $form->getSubmitValue('form_login');
$password = $form->getSubmitValue('form_password');
- // check the password
try {
- UsersManager::checkPassword($password);
+ $this->passwordResetter->initiatePasswordResetProcess($loginMail, $password);
} catch (Exception $ex) {
return array($ex->getMessage());
}
- // get the user's login
- if ($loginMail === 'anonymous') {
- return array(Piwik::translate('Login_InvalidUsernameEmail'));
- }
-
- $user = self::getUserInformation($loginMail);
- if ($user === null) {
- return array(Piwik::translate('Login_InvalidUsernameEmail'));
- }
-
- $login = $user['login'];
-
- // if valid, store password information in options table, then...
- Login::savePasswordResetInfo($login, $password);
-
- // ... send email with confirmation link
- try {
- $this->sendEmailConfirmationLink($user);
- } catch (Exception $ex) {
- // remove password reset info
- Login::removePasswordResetInfo($login);
-
- return array($ex->getMessage() . Piwik::translate('Login_ContactAdmin'));
- }
-
return null;
}
/**
- * 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 = self::generatePasswordResetToken($user);
-
- $ip = IP::getIpFromHeader();
- $url = Url::getCurrentUrlWithoutQueryString()
- . "?module=Login&action=confirmResetPassword&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);
-
- $fromEmailName = Config::getInstance()->General['login_password_recovery_email_name'];
- $fromEmailAddress = Config::getInstance()->General['login_password_recovery_email_address'];
- $mail->setFrom($fromEmailAddress, $fromEmailName);
-
- $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();
- }
-
- /**
* Password reset confirmation action. Finishes the password reset process.
* Users visit this action from a link supplied in an email.
*/
@@ -304,28 +254,14 @@ class Controller extends \Piwik\Plugin\Controller
$resetToken = Common::getRequestVar('resetToken', '');
try {
- // 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 = Login::getPasswordToResetTo($login);
- if ($resetPassword === false || !self::isValidToken($resetToken, $user)) {
- throw new Exception(Piwik::translate('Login_InvalidOrExpiredToken'));
- }
-
- // reset password of user
- $this->setNewUserPassword($user, $resetPassword);
+ $this->passwordResetter->confirmNewPassword($login, $resetToken);
} catch (Exception $ex) {
$errorMessage = $ex->getMessage();
}
- if (is_null($errorMessage)) // if success, show login w/ success message
- {
+ if (is_null($errorMessage)) { // if success, show login w/ success message
$this->redirectToIndex(Piwik::getLoginPluginName(), 'resetPasswordSuccess');
- return;
+ return null;
} else {
// show login page w/ error. this will keep the token in the URL
return $this->login($errorMessage);
@@ -333,20 +269,6 @@ class Controller extends \Piwik\Plugin\Controller
}
/**
- * Sets the password for a user.
- *
- * @param array $user User info.
- * @param string $passwordHash The hashed password to use.
- * @throws Exception
- */
- private function setNewUserPassword($user, $passwordHash)
- {
- $this->checkPasswordHash($passwordHash);
- API::getInstance()->updateUser(
- $user['login'], $passwordHash, $email = false, $alias = false, $isPasswordHashed = true);
- }
-
- /**
* The action used after a password is successfully reset. Displays the login
* screen with an extra message. A separate action is used instead of returning
* the HTML in confirmResetPassword so the resetToken won't be in the URL.
@@ -357,74 +279,6 @@ class Controller extends \Piwik\Plugin\Controller
}
/**
- * Get user information
- *
- * @param string $loginMail user login or email address
- * @return array ("login" => '...', "email" => '...', "password" => '...') or null, if user not found
- */
- protected function getUserInformation($loginMail)
- {
- Piwik::setUserHasSuperUserAccess();
-
- $user = null;
- if (API::getInstance()->userExists($loginMail)) {
- $user = API::getInstance()->getUser($loginMail);
- } else if (API::getInstance()->userEmailExists($loginMail)) {
- $user = API::getInstance()->getUserByEmail($loginMail);
- }
-
- return $user;
- }
-
- /**
- * Generate a password reset token. Expires in (roughly) 24 hours.
- *
- * @param array $user user information
- * @param int $timestamp Unix timestamp
- * @return string generated token
- */
- protected function generatePasswordResetToken($user, $timestamp = 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 (!$timestamp) {
- $timestamp = time() + 24 * 60 * 60; /* +24 hrs */
- }
-
- $expiry = strftime('%Y%m%d%H', $timestamp);
- $token = $this->generateHash(
- $expiry . $user['login'] . $user['email'],
- $user['password']
- );
- return $token;
- }
-
- /**
- * Validate token.
- *
- * @param string $token
- * @param array $user user information
- * @return bool true if valid, false otherwise
- */
- protected function isValidToken($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 = self::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;
- }
-
- /**
* Clear session information
*
* @param none
@@ -456,15 +310,4 @@ class Controller extends \Piwik\Plugin\Controller
Url::redirectToUrl($logoutUrl);
}
}
-
- /**
- * @param $password
- * @throws \Exception
- */
- protected function checkPasswordHash($password)
- {
- if (strlen($password) != 32) {
- throw new Exception(Piwik::translate('Login_ExceptionPasswordMD5HashExpected'));
- }
- }
}
diff --git a/plugins/Login/Login.php b/plugins/Login/Login.php
index a805094df8..4c33579176 100644
--- a/plugins/Login/Login.php
+++ b/plugins/Login/Login.php
@@ -103,52 +103,4 @@ class Login extends \Piwik\Plugin
$auth->setTokenAuth($defaultTokenAuth);
}
- /**
- * Stores password reset info for a specific login.
- *
- * @param string $login The user login for whom a password change was requested.
- * @param string $password The new password to set.
- */
- public static function savePasswordResetInfo($login, $password)
- {
- $optionName = self::getPasswordResetInfoOptionName($login);
- $optionData = UsersManager::getPasswordHash($password);
-
- Option::set($optionName, $optionData);
- }
-
- /**
- * Removes stored password reset info if it exists.
- *
- * @param string $login The user login to check for.
- */
- public static function removePasswordResetInfo($login)
- {
- $optionName = self::getPasswordResetInfoOptionName($login);
- Option::delete($optionName);
- }
-
- /**
- * 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.
- */
- public static function getPasswordToResetTo($login)
- {
- $optionName = self::getPasswordResetInfoOptionName($login);
- return Option::get($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';
- }
}
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