1<?php
2/**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 *
8 */
9namespace Piwik\Plugins\Login\Security;
10
11use Piwik\Common;
12use Piwik\Container\StaticContainer;
13use Piwik\Date;
14use Piwik\Db;
15use Piwik\Option;
16use Piwik\Plugins\Login\Emails\SuspiciousLoginAttemptsInLastHourEmail;
17use Piwik\Plugins\Login\Model;
18use Piwik\Plugins\Login\SystemSettings;
19use Piwik\Updater;
20use Piwik\Version;
21use Psr\Log\LoggerInterface;
22
23class BruteForceDetection {
24
25    const OVERALL_LOGIN_LOCKOUT_THRESHOLD_MIN = 10;
26    const TABLE_NAME = 'brute_force_log';
27
28    private $minutesTimeRange;
29    private $maxLogAttempts;
30
31    private $table = self::TABLE_NAME;
32    private $tablePrefixed = '';
33
34    /**
35     * @var SystemSettings
36     */
37    private $settings;
38
39    /**
40     * @var Updater
41     */
42    private $updater;
43
44    /**
45     * @var Model
46     */
47    private $model;
48
49    public function __construct(SystemSettings $systemSettings, Model $model)
50    {
51        $this->tablePrefixed = Common::prefixTable($this->table);
52        $this->settings = $systemSettings;
53        $this->minutesTimeRange = $systemSettings->loginAttemptsTimeRange->getValue();
54        $this->maxLogAttempts = $systemSettings->maxFailedLoginsPerMinutes->getValue();
55        $this->updater = new Updater();
56        $this->model = $model;
57    }
58
59    public function isEnabled()
60    {
61        $dbSchemaVersion = $this->updater->getCurrentComponentVersion('core');
62        if ($dbSchemaVersion && version_compare($dbSchemaVersion, '3.8.0') == -1) {
63            return false; // do not enable brute force detection before the tables exist
64        }
65
66        return $this->settings->enableBruteForceDetection->getValue();
67    }
68
69    public function addFailedAttempt($ipAddress, $login = null)
70    {
71        $now = $this->getNow()->getDatetime();
72        $db = Db::get();
73        try {
74            $db->query('INSERT INTO ' . $this->tablePrefixed . ' (ip_address, attempted_at, login) VALUES(?,?,?)', array($ipAddress, $now, $login));
75        } catch (\Exception $ex) {
76            $this->ignoreExceptionIfThrownDuringOneClickUpdate($ex);
77        }
78    }
79
80    public function isAllowedToLogin($ipAddress)
81    {
82        if ($this->settings->isBlacklistedIp($ipAddress)) {
83            return false;
84        }
85
86        if ($this->settings->isWhitelistedIp($ipAddress)) {
87            return true;
88        }
89
90        $db = Db::get();
91
92        $startTime = $this->getStartTimeRange();
93        $sql = 'SELECT count(*) as numLogins FROM '.$this->tablePrefixed.' WHERE ip_address = ? AND attempted_at > ?';
94        $numLogins = $db->fetchOne($sql, array($ipAddress, $startTime));
95
96        return empty($numLogins) || $numLogins <= $this->maxLogAttempts;
97    }
98
99    public function getCurrentlyBlockedIps()
100    {
101        $sql = 'SELECT ip_address
102                FROM ' . $this->tablePrefixed . '
103                WHERE attempted_at > ?
104                GROUP BY ip_address
105                HAVING count(*) > ' . (int) $this->maxLogAttempts;
106        $rows = Db::get()->fetchAll($sql, array($this->getStartTimeRange()));
107
108        $ips = array();
109        foreach ($rows as $row) {
110            if ($this->settings->isWhitelistedIp($row['ip_address'])) {
111                continue;
112            }
113            $ips[] = $row['ip_address'];
114        }
115
116        return $ips;
117    }
118
119    public function unblockIp($ip)
120    {
121        // we only delete where attempted_at was recent and keep other IPs for history purposes
122        Db::get()->query('DELETE FROM '.$this->tablePrefixed.' WHERE ip_address = ? and attempted_at > ?', array($ip, $this->getStartTimeRange()));
123    }
124
125    public function cleanupOldEntries()
126    {
127        // we delete all entries older than 7 days (or more if more attempts are logged)
128        $minutesAutoDelete = 10080;
129
130        $minutes = max($minutesAutoDelete, $this->minutesTimeRange);
131        $deleteOlderDate = $this->getDateTimeSubMinutes($minutes);
132        Db::get()->query('DELETE FROM '.$this->tablePrefixed.' WHERE attempted_at < ?', array($deleteOlderDate));
133    }
134
135    /**
136     * @internal tests only
137     */
138    public function deleteAll()
139    {
140        return Db::query('DELETE FROM ' . $this->tablePrefixed);
141    }
142
143    /**
144     * @internal tests only
145     */
146    public function getAll()
147    {
148        return Db::get()->fetchAll('SELECT * FROM ' . $this->tablePrefixed);
149    }
150
151    protected function getNow()
152    {
153        return Date::now();
154    }
155
156    private function getStartTimeRange()
157    {
158        return $this->getDateTimeSubMinutes($this->minutesTimeRange);
159    }
160
161    private function getDateTimeSubMinutes($minutes)
162    {
163        return $this->getNow()->subPeriod($minutes, 'minute')->getDatetime();
164    }
165
166    public function isUserLoginBlocked($login)
167    {
168        $count = 0;
169        try {
170            $count = $this->model->getTotalLoginAttemptsInLastHourForLogin($login);
171        } catch (\Exception $ex) {
172            $this->ignoreExceptionIfThrownDuringOneClickUpdate($ex);
173        }
174
175        if (!$this->hasTooManyTriesOverallInlastHour($count)) {
176            return false;
177        }
178
179        if (!$this->model->hasNotifiedUserAboutSuspiciousLogins($login)) {
180            $this->sendSuspiciousLoginsEmailToUser($login, $count);
181        }
182
183        return true;
184    }
185
186    private function hasTooManyTriesOverallInLastHour($count)
187    {
188        return $count > $this->getOverallLoginLockoutThreshold();
189    }
190
191    private function sendSuspiciousLoginsEmailToUser($login, $countOverall)
192    {
193        $distinctIps = $this->model->getDistinctIpsAttemptingLoginsInLastHour($login);
194
195        try {
196            // create from DI container so plugins can modify email contents if they want
197            $email = StaticContainer::getContainer()->make(SuspiciousLoginAttemptsInLastHourEmail::class, [
198                'login' => $login,
199                'countOverall' => $countOverall,
200                'countDistinctIps' => $distinctIps
201            ]);
202            $email->send();
203
204            $this->model->markSuspiciousLoginsNotifiedEmailSent($login);
205        } catch (\Exception $ex) {
206            // log if error is not that we can't find a user
207            if (strpos($ex->getMessage(), 'unable to find user to send') === false) {
208                StaticContainer::get(LoggerInterface::class)->info(
209                    'Error when sending ' . SuspiciousLoginAttemptsInLastHourEmail::class . ' email. User exists but encountered {exception}', [
210                    'exception' => $ex,
211                ]);
212            }
213        }
214    }
215
216    protected function getOverallLoginLockoutThreshold()
217    {
218        $settings = new SystemSettings();
219        $threshold = $settings->maxFailedLoginsPerMinutes->getValue() * 3;
220        return max(self::OVERALL_LOGIN_LOCKOUT_THRESHOLD_MIN, $threshold);
221    }
222
223    private function ignoreExceptionIfThrownDuringOneClickUpdate(\Exception $ex)
224    {
225        // ignore column not found errors during one click update since the db will not be up to date while new code is being used
226        $module = Common::getRequestVar('module', false);
227        if (strpos($ex->getMessage(), 'Unknown column') === false
228            || $module != 'CoreUpdater'
229        ) {
230            throw $ex;
231        }
232    }
233}
234