<?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\API\Request;
use Piwik\Request\AuthenticationToken;
use Piwik\Common;
use Piwik\Config;
use Piwik\Container\StaticContainer;
use Piwik\FrontController;
use Piwik\IP;
use Piwik\NoAccessException;
use Piwik\Piwik;
use Piwik\Plugins\Login\Security\BruteForceDetection;
use Piwik\Plugins\Login\Security\LoginFromDifferentCountryDetection;
use Piwik\Session;
use Piwik\SettingsServer;

/**
 *
 */
class Login extends \Piwik\Plugin
{
    private $hasAddedFailedAttempt = false;
    private $hasPerformedBruteForceCheck = false;
    private $hasPerformedBruteForceCheckForUserPwdLogin = false;

    /**
     * @see \Piwik\Plugin::registerEvents
     */
    public function registerEvents()
    {
        $hooks = array(
            'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys',
            'User.isNotAuthorized'             => 'noAccess',
            'API.Request.authenticate'         => 'apiRequestAuthenticate',
            'AssetManager.getJavaScriptFiles'  => 'getJsFiles',
            'AssetManager.getStylesheetFiles'  => 'getStylesheetFiles',
            'Session.beforeSessionStart'       => 'beforeSessionStart',

            // for brute force prevention of all tracking + reporting api requests
            'Request.initAuthenticationObject' => 'onInitAuthenticationObject',
            'API.UsersManager.createAppSpecificTokenAuth' => 'beforeLoginCheckBruteForce', // doesn't require auth but can be used to authenticate

            // for brute force prevention of all UI requests
            'Controller.Login.logme'           => 'beforeLoginCheckBruteForce',
            'Controller.Login.'                => 'beforeLoginCheckBruteForce',
            'Controller.Login.index'           => 'beforeLoginCheckBruteForce',
            'Controller.Login.confirmResetPassword' => 'beforeLoginCheckBruteForce',
            'Controller.Login.confirmPassword' => 'beforeLoginCheckBruteForce',
            'Controller.Login.resetPassword'   => 'beforeLoginCheckBruteForce',
            'Controller.Login.login'           => 'beforeLoginCheckBruteForce',
            'Controller.TwoFactorAuth.loginTwoFactorAuth' => 'beforeLoginCheckBruteForce',
            'Controller.Login.acceptInvitation' => 'beforeLoginCheckBruteForce',
            'Controller.Login.declineInvitation' => 'beforeLoginCheckBruteForce',
            'Login.authenticate.successful'    => 'beforeLoginCheckBruteForce',
            'Login.beforeLoginCheckAllowed'    => 'beforeLoginCheckBruteForce',
            'Login.recordFailedLoginAttempt'   => 'onFailedLoginRecordAttempt', // record any failed attempt in UI
            'Login.authenticate.failed'        => 'onFailedLoginRecordAttempt', // record any failed attempt in UI
            'API.Request.authenticate.failed'  => 'onFailedAPILogin', // record any failed attempt in Reporting API
            'Tracker.Request.authenticate.failed' => 'onFailedLoginRecordAttempt', // record any failed attempt in Tracker API

            // for 'Login from a different country' notification
            'Login.authenticate.processSuccessfulSession.end' => 'checkLoginFromAnotherCountry',
        );

        $loginPlugin = Piwik::getLoginPluginName();

        if ($loginPlugin && $loginPlugin !== 'Login') {
            $hooks['Controller.' . $loginPlugin . '.logme']           = 'beforeLoginCheckBruteForce';
            $hooks['Controller.' . $loginPlugin . '.']                = 'beforeLoginCheckBruteForce';
            $hooks['Controller.' . $loginPlugin . '.index']           = 'beforeLoginCheckBruteForce';
            $hooks['Controller.' . $loginPlugin . '.confirmResetPassword'] = 'beforeLoginCheckBruteForce';
            $hooks['Controller.' . $loginPlugin . '.confirmPassword'] = 'beforeLoginCheckBruteForce';
            $hooks['Controller.' . $loginPlugin . '.resetPassword']   = 'beforeLoginCheckBruteForce';
            $hooks['Controller.' . $loginPlugin . '.login']           = 'beforeLoginCheckBruteForce';
        }

        return $hooks;
    }

    public function getClientSideTranslationKeys(&$translations)
    {
        $translations[] = 'Login_CurrentlyBlockedIPs';
        $translations[] = 'Login_CurrentlyBlockedIPsUnblockInfo';
        $translations[] = 'Login_UnblockAllIPs';
        $translations[] = 'Login_CurrentlyBlockedIPsUnblockConfirm';
        $translations[] = 'Login_IPsAlwaysBlocked';
    }

    public function isTrackerPlugin()
    {
        return true;
    }

    public function onInitAuthenticationObject()
    {
        if (SettingsServer::isTrackerApiRequest() || Request::isRootRequestApiRequest()) {
            // we check it for all API requests...
            // we do not check it for other UI requests as otherwise we would be logging out someone possibly already
            // logged in with a valid session which we don't want currently... regular UI requests are checked through
            // 1) any successful or failed login attempt, plus through specific controller action that a user can use
            // to log in
            $this->beforeLoginCheckBruteForce();
        }
    }

    public function onFailedLoginRecordAttempt()
    {
        // we're always making sure on any success or failed login to check if user is actually allowed to log in
        // in case for some reason it forgot to run the check
        $this->beforeLoginCheckBruteForce();

        // we are recording new failed attempts only when user can currently log in and is not blocked...
        // this is to kind of block eg a certain IP continuously. could alternatively also still keep writing those failed
        // attempts into the log and only allow login attempts again after the user had no login attempts for the configured
        // time frame
        $bruteForce = StaticContainer::get('Piwik\Plugins\Login\Security\BruteForceDetection');
        if ($bruteForce->isEnabled() && !$this->hasAddedFailedAttempt) {
            $login = $this->getUsernameUsedInPasswordLogin();
            $bruteForce->addFailedAttempt(IP::getIpFromHeader(), $login);
            // we make sure to log max one failed login attempt per request... otherwise we might log 3 or many more
            // if eg API is called etc.
            $this->hasAddedFailedAttempt = true;
        }
    }

    public function onFailedAPILogin()
    {
        $this->onFailedLoginRecordAttempt();

        // Only throw an exception if this is an API request
        if ($this->isModuleIsAPI()) {
            // Throw an exception if a token was provided but it was invalid
            if (StaticContainer::get(AuthenticationToken::class)->wasTokenAuthProvidedSecurely()) {
                throw new NoAccessException('Unable to authenticate with the provided token. It is either invalid or expired.');
            } else {
                throw new NoAccessException('Unable to authenticate with the provided token. It is either invalid, expired or is required to be sent as a POST parameter.');
            }
        }
    }

    public function checkLoginFromAnotherCountry($login)
    {
        if ('anonymous' === $login) {
            // do not send notification to "anonymous"
            return;
        }

        $loginFromDifferentCountryDetection = StaticContainer::get(LoginFromDifferentCountryDetection::class);
        if ($loginFromDifferentCountryDetection->isEnabled()) {
            $loginFromDifferentCountryDetection->check($login);
        }
    }

    public function beforeLoginCheckBruteForce()
    {
        $bruteForce = StaticContainer::get('Piwik\Plugins\Login\Security\BruteForceDetection');
        if (!$this->hasPerformedBruteForceCheck && $bruteForce->isEnabled() && !$bruteForce->isAllowedToLogin(IP::getIpFromHeader())) {
            throw new Exception(Piwik::translate('Login_LoginNotAllowedBecauseBlocked'));
        }

        // for performance reasons we make sure to execute it only once per request
        $this->hasPerformedBruteForceCheck = true;

        // now check that user login (from any ip) is not blocked
        $login = $this->getUsernameUsedInPasswordLogin();
        if (
            empty($login)
            || $login == 'anonymous'
        ) {
            return; // can't do the check if we don't know the login
        }

        /** @var BruteForceDetection $bruteForce */
        $bruteForce = StaticContainer::get('Piwik\Plugins\Login\Security\BruteForceDetection');
        if (!$this->hasPerformedBruteForceCheckForUserPwdLogin && $bruteForce->isEnabled() && $bruteForce->isUserLoginBlocked($login)) {
            $ex = new NoAccessException(Piwik::translate('Login_LoginNotAllowedBecauseUserLoginBlocked'), 403);
            throw $ex;
        }
        // for performance reasons we make sure to execute it only once per request
        $this->hasPerformedBruteForceCheckForUserPwdLogin = true;
    }

    public function getJsFiles(&$jsFiles)
    {
        $jsFiles[] = "plugins/Login/javascripts/login.js";
        $jsFiles[] = "plugins/Login/javascripts/bruteforcelog.js";
    }

    public function getStylesheetFiles(&$stylesheetFiles)
    {
        $stylesheetFiles[] = "plugins/Login/stylesheets/login.less";
        $stylesheetFiles[] = "plugins/Login/stylesheets/variables.less";
    }

    public function beforeSessionStart()
    {
        if (!$this->shouldHandleRememberMe()) {
            return;
        }

        // if this is a login request & form_rememberme was set, change the session cookie expire time before starting the session
        $rememberMe = isset($_POST['form_rememberme']) ? $_POST['form_rememberme'] : null;
        if ($rememberMe == '1') {
            Session::rememberMe(Config::getInstance()->General['login_cookie_expire']);
        }
    }

    private function shouldHandleRememberMe()
    {
        $module = Piwik::getModule();
        $action = Piwik::getAction();
        return ($module == 'Login' || $module == 'CoreHome') && (empty($action) || $action == 'index' || $action == 'login');
    }

    /**
     * Redirects to Login form with error message.
     * Listens to User.isNotAuthorized hook.
     */
    public function noAccess(Exception $exception)
    {
        $frontController = FrontController::getInstance();

        if (Common::isXmlHttpRequest()) {
            echo $frontController->dispatch(Piwik::getLoginPluginName(), 'ajaxNoAccess', array($exception->getMessage()));
            return;
        }

        echo $frontController->dispatch(Piwik::getLoginPluginName(), 'login', array($exception->getMessage()));
    }

    /**
     * Set login name and authentication token for API request.
     * Listens to API.Request.authenticate hook.
     */
    public function apiRequestAuthenticate(
        #[\SensitiveParameter]
        $tokenAuth
    ) {
        $this->beforeLoginCheckBruteForce();

        /** @var \Piwik\Auth $auth */
        $auth = StaticContainer::get('Piwik\Auth');
        $auth->setLogin($login = null);
        $auth->setTokenAuth($tokenAuth);
    }

    protected static function isModuleIsAPI()
    {
        return Piwik::getModule() === 'API'
                && (Piwik::getAction() == '' || Piwik::getAction() == 'index');
    }

    private function getUsernameUsedInPasswordLogin()
    {
        $login = StaticContainer::get(\Piwik\Auth::class)->getLogin();
        if (empty($login) || $login == 'anonymous') {
            $login = \Piwik\Request::fromRequest()->getStringParameter('form_login', '');
            if (Piwik::getAction() === 'logme') {
                $login = \Piwik\Request::fromRequest()->getStringParameter('login', $login);
            }
        }

        return $login;
    }
}
