diff options
-rw-r--r-- | core/Auth.php | 70 | ||||
-rw-r--r-- | core/Piwik.php | 30 | ||||
-rw-r--r-- | plugins/Login/Auth.php | 37 | ||||
-rw-r--r-- | plugins/Login/Controller.php | 249 | ||||
-rw-r--r-- | plugins/Login/Login.php | 48 | ||||
-rw-r--r-- | plugins/Login/PasswordResetter.php | 476 |
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 |