<?php
/**
 * Matomo - free/libre analytics platform
 *
 * @link https://matomo.org
 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 *
 */
namespace Piwik\Plugins\Login\Security;

use Piwik\Common;
use Piwik\Container\StaticContainer;
use Piwik\Date;
use Piwik\Db;
use Piwik\Plugins\Login\Emails\SuspiciousLoginAttemptsInLastHourEmail;
use Piwik\Plugins\Login\Model;
use Piwik\Plugins\Login\SystemSettings;
use Piwik\Updater;
use Psr\Log\LoggerInterface;

class BruteForceDetection {

    const OVERALL_LOGIN_LOCKOUT_THRESHOLD_MIN = 10;
    const TABLE_NAME = 'brute_force_log';

    private $minutesTimeRange;
    private $maxLogAttempts;

    private $table = self::TABLE_NAME;
    private $tablePrefixed = '';

    /**
     * @var SystemSettings
     */
    private $settings;

    /**
     * @var Updater
     */
    private $updater;

    /**
     * @var Model
     */
    private $model;

    public function __construct(SystemSettings $systemSettings, Model $model)
    {
        $this->tablePrefixed = Common::prefixTable($this->table);
        $this->settings = $systemSettings;
        $this->minutesTimeRange = $systemSettings->loginAttemptsTimeRange->getValue();
        $this->maxLogAttempts = $systemSettings->maxFailedLoginsPerMinutes->getValue();
        $this->updater = new Updater();
        $this->model = $model;
    }

    public function isEnabled()
    {
        $dbSchemaVersion = $this->updater->getCurrentComponentVersion('core');
        if ($dbSchemaVersion && version_compare($dbSchemaVersion, '3.8.0') == -1) {
            return false; // do not enable brute force detection before the tables exist
        }

        return $this->settings->enableBruteForceDetection->getValue();
    }

    public function addFailedAttempt($ipAddress, $login = null)
    {
        $now = $this->getNow()->getDatetime();
        $db = Db::get();
        try {
            $db->query('INSERT INTO ' . $this->tablePrefixed . ' (ip_address, attempted_at, login) VALUES(?,?,?)', array($ipAddress, $now, $login));
        } catch (\Exception $ex) {
            $this->ignoreExceptionIfThrownDuringOneClickUpdate($ex);
        }
    }

    public function isAllowedToLogin($ipAddress)
    {
        if ($this->settings->isBlacklistedIp($ipAddress)) {
            return false;
        }

        if ($this->settings->isWhitelistedIp($ipAddress)) {
            return true;
        }

        $db = Db::get();

        $startTime = $this->getStartTimeRange();
        $sql = 'SELECT count(*) as numLogins FROM '.$this->tablePrefixed.' WHERE ip_address = ? AND attempted_at > ?';
        $numLogins = $db->fetchOne($sql, array($ipAddress, $startTime));

        return empty($numLogins) || $numLogins <= $this->maxLogAttempts;
    }

    public function getCurrentlyBlockedIps()
    {
        $sql = 'SELECT ip_address
                FROM ' . $this->tablePrefixed . ' 
                WHERE attempted_at > ?
                GROUP BY ip_address 
                HAVING count(*) > ' . (int) $this->maxLogAttempts;
        $rows = Db::get()->fetchAll($sql, array($this->getStartTimeRange()));

        $ips = array();
        foreach ($rows as $row) {
            if ($this->settings->isWhitelistedIp($row['ip_address'])) {
                continue;
            }
            $ips[] = $row['ip_address'];
        }

        return $ips;
    }

    public function unblockIp($ip)
    {
        // we only delete where attempted_at was recent and keep other IPs for history purposes
        Db::get()->query('DELETE FROM '.$this->tablePrefixed.' WHERE ip_address = ? and attempted_at > ?', array($ip, $this->getStartTimeRange()));
    }

    public function cleanupOldEntries()
    {
        // we delete all entries older than 7 days (or more if more attempts are logged)
        $minutesAutoDelete = 10080;

        $minutes = max($minutesAutoDelete, $this->minutesTimeRange);
        $deleteOlderDate = $this->getDateTimeSubMinutes($minutes);
        Db::get()->query('DELETE FROM '.$this->tablePrefixed.' WHERE attempted_at < ?', array($deleteOlderDate));
    }

    /**
     * @internal tests only
     */
    public function deleteAll()
    {
        return Db::query('DELETE FROM ' . $this->tablePrefixed);
    }

    /**
     * @internal tests only
     */
    public function getAll()
    {
        return Db::get()->fetchAll('SELECT * FROM ' . $this->tablePrefixed);
    }

    protected function getNow()
    {
        return Date::now();
    }

    private function getStartTimeRange()
    {
        return $this->getDateTimeSubMinutes($this->minutesTimeRange);
    }

    private function getDateTimeSubMinutes($minutes)
    {
        return $this->getNow()->subPeriod($minutes, 'minute')->getDatetime();
    }

    public function isUserLoginBlocked($login)
    {
        $count = 0;
        try {
            $count = $this->model->getTotalLoginAttemptsInLastHourForLogin($login);
        } catch (\Exception $ex) {
            $this->ignoreExceptionIfThrownDuringOneClickUpdate($ex);
        }

        if (!$this->hasTooManyTriesOverallInlastHour($count)) {
            return false;
        }

        if (!$this->model->hasNotifiedUserAboutSuspiciousLogins($login)) {
            $this->sendSuspiciousLoginsEmailToUser($login, $count);
        }

        return true;
    }

    private function hasTooManyTriesOverallInLastHour($count)
    {
        return $count > $this->getOverallLoginLockoutThreshold();
    }

    private function sendSuspiciousLoginsEmailToUser($login, $countOverall)
    {
        $distinctIps = $this->model->getDistinctIpsAttemptingLoginsInLastHour($login);

        try {
            // create from DI container so plugins can modify email contents if they want
            $email = StaticContainer::getContainer()->make(SuspiciousLoginAttemptsInLastHourEmail::class, [
                'login' => $login,
                'countOverall' => $countOverall,
                'countDistinctIps' => $distinctIps
            ]);
            $email->send();

            $this->model->markSuspiciousLoginsNotifiedEmailSent($login);
        } catch (\Exception $ex) {
            // log if error is not that we can't find a user
            if (strpos($ex->getMessage(), 'unable to find user to send') === false) {
                StaticContainer::get(LoggerInterface::class)->info(
                    'Error when sending ' . SuspiciousLoginAttemptsInLastHourEmail::class . ' email. User exists but encountered {exception}', [
                    'exception' => $ex,
                ]);
            }
        }
    }

    protected function getOverallLoginLockoutThreshold()
    {
        $settings = new SystemSettings();
        $threshold = $settings->maxFailedLoginsPerMinutes->getValue() * 3;
        return max(self::OVERALL_LOGIN_LOCKOUT_THRESHOLD_MIN, $threshold);
    }

    private function ignoreExceptionIfThrownDuringOneClickUpdate(\Exception $ex)
    {
        // ignore column not found errors during one click update since the db will not be up to date while new code is being used
        $module = Common::getRequestVar('module', false);
        if (strpos($ex->getMessage(), 'Unknown column') === false
            || $module != 'CoreUpdater'
        ) {
            throw $ex;
        }
    }
}
