<?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\UserLoginHelper;
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 setHashedPasswordForLogin()}.
 *
 * 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;

    /**
     * @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 (strtolower($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;
    }

    /**
     * Sets the given already-hashed password as the specified user's password (executed as super
     * user, so it can run before the user is authenticated during a password reset).
     *
     * @param string $login The login of the user whose password is being set.
     * @param string $passwordHash The already-hashed password to set for the user.
     */
    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<string, mixed>|null `array("login" => '...', "email" => '...', "password" => '...')` or null, if user not found.
     */
    protected function getUserInformation($loginOrMail)
    {
        $userModel = new Model();

        if ($userModel->isPendingUser($loginOrMail)) {
            return null;
        }

        return UserLoginHelper::findUserByLoginOrEmail($loginOrMail);
    }

    /**
     * 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';
    }
}
