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