forked from vergnet/site-accueil-insa
231 lines
7.1 KiB
PHP
231 lines
7.1 KiB
PHP
<?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;
|
|
}
|
|
}
|
|
}
|