Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/matomo-org/matomo.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Steur <tsteur@users.noreply.github.com>2018-12-10 22:47:02 +0300
committerdiosmosis <diosmosis@users.noreply.github.com>2018-12-10 22:47:02 +0300
commit491ff0d8ecd7d2e9daeeb103a2a7fc099526dff9 (patch)
tree9a242e11fcda26bc8cace3de4594c916acbe9f1b /plugins/Login
parent43b61590e51980965c8c9731d79e0b1479e8feb6 (diff)
Lock down accounts by IP after N failed attemps at logging in (#13472)
* some basic work on preventing brute force attacks * change order * delete depending on configured value * show log and feature to unblock ips etc * more tweaks * lots of fixes, improvements, and tests * add more tests * add more fixes * fix typo * make sure to check for all API requests whether allowed * apply feedback * block more usages * improve usage * fix some tests * fix some tests * fix memory problem * do not whitelist ips for brute force tests * trying to fix tests * only delete if installed * use query * fix some tests * better fix * fix some tests * fix ui tests * fix more tests
Diffstat (limited to 'plugins/Login')
-rw-r--r--plugins/Login/API.php42
-rw-r--r--plugins/Login/Commands/UnblockBlockedIps.php33
-rw-r--r--plugins/Login/Controller.php57
-rw-r--r--plugins/Login/Login.php89
-rw-r--r--plugins/Login/Menu.php23
-rw-r--r--plugins/Login/PasswordVerifier.php29
-rw-r--r--plugins/Login/Security/BruteForceDetection.php134
-rw-r--r--plugins/Login/SystemSettings.php135
-rw-r--r--plugins/Login/Tasks.php35
-rw-r--r--plugins/Login/config/test.php36
-rw-r--r--plugins/Login/javascripts/bruteforcelog.js26
-rw-r--r--plugins/Login/lang/en.json16
-rw-r--r--plugins/Login/templates/bruteForceLog.twig42
-rw-r--r--plugins/Login/tests/Integration/APITest.php68
-rw-r--r--plugins/Login/tests/Integration/Security/BruteForceDetectionTest.php260
-rw-r--r--plugins/Login/tests/Integration/SystemSettingsTest.php146
-rw-r--r--plugins/Login/tests/UI/Login_spec.js81
-rw-r--r--plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedapi.png3
-rw-r--r--plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedlogin.png3
-rw-r--r--plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedlogme.png3
-rw-r--r--plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_noentries.png3
-rw-r--r--plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_withentries.png3
22 files changed, 1246 insertions, 21 deletions
diff --git a/plugins/Login/API.php b/plugins/Login/API.php
new file mode 100644
index 0000000000..89c0eaa609
--- /dev/null
+++ b/plugins/Login/API.php
@@ -0,0 +1,42 @@
+<?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;
+
+use Piwik\Piwik;
+use Piwik\Plugins\Login\Security\BruteForceDetection;
+
+/**
+ * API for plugin Login
+ *
+ * @method static \Piwik\Plugins\Login\API getInstance()
+ */
+class API extends \Piwik\Plugin\API
+{
+ /**
+ * @var BruteForceDetection
+ */
+ private $bruteForceDetection;
+
+ public function __construct(BruteForceDetection $bruteForceDetection)
+ {
+ $this->bruteForceDetection = $bruteForceDetection;
+ }
+
+ public function unblockBruteForceIPs()
+ {
+ Piwik::checkUserHasSuperUserAccess();
+
+ $ips = $this->bruteForceDetection->getCurrentlyBlockedIps();
+ if (!empty($ips)) {
+ foreach ($ips as $ip) {
+ $this->bruteForceDetection->unblockIp($ip);
+ }
+ }
+ }
+}
diff --git a/plugins/Login/Commands/UnblockBlockedIps.php b/plugins/Login/Commands/UnblockBlockedIps.php
new file mode 100644
index 0000000000..d736f2fb99
--- /dev/null
+++ b/plugins/Login/Commands/UnblockBlockedIps.php
@@ -0,0 +1,33 @@
+<?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\Commands;
+
+use Piwik\API\Request;
+use Piwik\Piwik;
+use Piwik\Plugin\ConsoleCommand;
+use Piwik\Plugins\Login\API;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class UnblockBlockedIps extends ConsoleCommand
+{
+ protected function configure()
+ {
+ $this->setName('login:unblock-blocked-ips');
+ $this->setDescription('Unblocks all currently blocked IPs. Useful if you cannot log in to your Matomo anymore because your own IP is blocked');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ Request::processRequest('Login.unblockBruteForceIPs');
+ $message = sprintf('<info>%s</info>', Piwik::translate('General_Done'));
+
+ $output->writeln($message);
+ }
+}
diff --git a/plugins/Login/Controller.php b/plugins/Login/Controller.php
index 2db9f15588..2d820d5e9f 100644
--- a/plugins/Login/Controller.php
+++ b/plugins/Login/Controller.php
@@ -16,6 +16,7 @@ use Piwik\Date;
use Piwik\Log;
use Piwik\Nonce;
use Piwik\Piwik;
+use Piwik\Plugins\Login\Security\BruteForceDetection;
use Piwik\Plugins\UsersManager\Model AS UsersModel;
use Piwik\QuickForm2;
use Piwik\Session;
@@ -26,7 +27,7 @@ use Piwik\View;
* Login controller
* @api
*/
-class Controller extends \Piwik\Plugin\Controller
+class Controller extends \Piwik\Plugin\ControllerAdmin
{
/**
* @var PasswordResetter
@@ -44,6 +45,16 @@ class Controller extends \Piwik\Plugin\Controller
protected $sessionInitializer;
/**
+ * @var BruteForceDetection
+ */
+ protected $bruteForceDetection;
+
+ /**
+ * @var SystemSettings
+ */
+ protected $systemSettings;
+
+ /*
* @var PasswordVerifier
*/
protected $passwordVerify;
@@ -53,10 +64,12 @@ class Controller extends \Piwik\Plugin\Controller
*
* @param PasswordResetter $passwordResetter
* @param AuthInterface $auth
- * @param SessionInitializer $authenticatedSessionFactory
+ * @param SessionInitializer $sessionInitializer
* @param PasswordVerifier $passwordVerify
+ * @param BruteForceDetection $bruteForceDetection
+ * @param SystemSettings $systemSettings
*/
- public function __construct($passwordResetter = null, $auth = null, $sessionInitializer = null, $passwordVerify = null)
+ public function __construct($passwordResetter = null, $auth = null, $sessionInitializer = null, $passwordVerify = null, $bruteForceDetection = null, $systemSettings = null)
{
parent::__construct();
@@ -79,6 +92,16 @@ class Controller extends \Piwik\Plugin\Controller
$sessionInitializer = new \Piwik\Session\SessionInitializer();
}
$this->sessionInitializer = $sessionInitializer;
+
+ if (empty($bruteForceDetection)) {
+ $bruteForceDetection = StaticContainer::get('Piwik\Plugins\Login\Security\BruteForceDetection');
+ }
+ $this->bruteForceDetection = $bruteForceDetection;
+
+ if (empty($systemSettings)) {
+ $systemSettings = StaticContainer::get('Piwik\Plugins\Login\SystemSettings');
+ }
+ $this->systemSettings = $systemSettings;
}
/**
@@ -151,7 +174,7 @@ class Controller extends \Piwik\Plugin\Controller
*/
protected function configureView($view)
{
- $this->setBasicVariablesView($view);
+ $this->setBasicVariablesNoneAdminView($view);
$view->linkTitle = Piwik::getRandomTitle();
@@ -174,11 +197,13 @@ class Controller extends \Piwik\Plugin\Controller
$nonceKey = 'confirmPassword';
$messageNoAccess = '';
+
if (!empty($_POST)) {
$nonce = Common::getRequestVar('nonce', null, 'string', $_POST);
+ $password = Common::getRequestVar('password', null, 'string', $_POST);
if (!Nonce::verifyNonce($nonceKey, $nonce)) {
$messageNoAccess = $this->getMessageExceptionNoAccess();
- } elseif ($this->verifyPasswordCorrect()) {
+ } elseif ($this->passwordVerify->isPasswordCorrect(Piwik::getCurrentUserLogin(), $password)) {
$this->passwordVerify->setPasswordVerifiedCorrectly();
return;
} else {
@@ -192,18 +217,6 @@ class Controller extends \Piwik\Plugin\Controller
));
}
- private function verifyPasswordCorrect()
- {
- /** @var \Piwik\Auth $authAdapter */
- $authAdapter = StaticContainer::get('Piwik\Auth');
- $authAdapter->setLogin(Piwik::getCurrentUserLogin());
- $authAdapter->setPasswordHash(null);// ensure authentication happens on password
- $authAdapter->setPassword(Common::getRequestVar('password', null, 'string', $_POST));
- $authAdapter->setTokenAuth(null);// ensure authentication happens on password
- $authResult = $authAdapter->authenticate();
- return $authResult->wasAuthenticationSuccessful();
- }
-
/**
* Form-less login
* @see how to use it on http://piwik.org/faq/how-to/#faq_30
@@ -231,6 +244,16 @@ class Controller extends \Piwik\Plugin\Controller
$this->authenticateAndRedirect($login, $password, $urlToRedirect, $passwordHashed = true);
}
+ public function bruteForceLog()
+ {
+ Piwik::checkUserHasSuperUserAccess();
+
+ return $this->renderTemplate('bruteForceLog', array(
+ 'blockedIps' => $this->bruteForceDetection->getCurrentlyBlockedIps(),
+ 'blacklistedIps' => $this->systemSettings->blacklistedBruteForceIps->getValue()
+ ));
+ }
+
/**
* Error message shown when an AJAX request has no access
*
diff --git a/plugins/Login/Login.php b/plugins/Login/Login.php
index bc1f0bd302..fdb85c09c1 100644
--- a/plugins/Login/Login.php
+++ b/plugins/Login/Login.php
@@ -13,18 +13,20 @@ use Piwik\API\Request;
use Piwik\Common;
use Piwik\Config;
use Piwik\Container\StaticContainer;
-use Piwik\Cookie;
-use Piwik\Date;
use Piwik\FrontController;
+use Piwik\IP;
use Piwik\Piwik;
use Piwik\Session;
-use Piwik\Url;
+use Piwik\SettingsServer;
/**
*
*/
class Login extends \Piwik\Plugin
{
+ private $hasAddedFailedAttempt = false;
+ private $hasPerformedBruteForceCheck = false;
+
/**
* @see \Piwik\Plugin::registerEvents
*/
@@ -36,13 +38,92 @@ class Login extends \Piwik\Plugin
'AssetManager.getJavaScriptFiles' => 'getJsFiles',
'AssetManager.getStylesheetFiles' => 'getStylesheetFiles',
'Session.beforeSessionStart' => 'beforeSessionStart',
+
+ // for brute force prevention of all tracking + reporting api requests
+ 'Request.initAuthenticationObject' => 'onInitAuthenticationObject',
+ 'API.UsersManager.getTokenAuth' => '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',
+ 'Login.authenticate.successful' => 'beforeLoginCheckBruteForce',
+ 'Login.beforeLoginCheckAllowed' => 'beforeLoginCheckBruteForce', // record any failed attempt in UI
+ 'Login.recordFailedLoginAttempt' => 'onFailedLoginRecordAttempt', // record any failed attempt in UI
+ 'Login.authenticate.failed' => 'onFailedLoginRecordAttempt', // record any failed attempt in UI
+ 'API.Request.authenticate.failed' => 'onFailedLoginRecordAttempt', // record any failed attempt in Reporting API
+ 'Tracker.Request.authenticate.failed' => 'onFailedLoginRecordAttempt', // record any failed attempt in Tracker API
);
+
+ $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 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) {
+ $bruteForce->addFailedAttempt(IP::getIpFromHeader());
+ // 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 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;
+ }
+
public function getJsFiles(&$jsFiles)
{
$jsFiles[] = "plugins/Login/javascripts/login.js";
+ $jsFiles[] = "plugins/Login/javascripts/bruteforcelog.js";
}
public function getStylesheetFiles(&$stylesheetFiles)
@@ -93,6 +174,8 @@ class Login extends \Piwik\Plugin
*/
public function ApiRequestAuthenticate($tokenAuth)
{
+ $this->beforeLoginCheckBruteForce();
+
/** @var \Piwik\Auth $auth */
$auth = StaticContainer::get('Piwik\Auth');
$auth->setLogin($login = null);
diff --git a/plugins/Login/Menu.php b/plugins/Login/Menu.php
new file mode 100644
index 0000000000..82976c83c4
--- /dev/null
+++ b/plugins/Login/Menu.php
@@ -0,0 +1,23 @@
+<?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;
+
+use Piwik\Menu\MenuAdmin;
+use Piwik\Piwik;
+
+class Menu extends \Piwik\Plugin\Menu
+{
+
+ public function configureAdminMenu(MenuAdmin $menu)
+ {
+ if (Piwik::hasUserSuperUserAccess()) {
+ $menu->addDiagnosticItem('Login_BruteForceLog', $this->urlForAction('bruteForceLog'), $orderId = 30);
+ }
+ }
+}
diff --git a/plugins/Login/PasswordVerifier.php b/plugins/Login/PasswordVerifier.php
index b3909eb71c..d95ba17804 100644
--- a/plugins/Login/PasswordVerifier.php
+++ b/plugins/Login/PasswordVerifier.php
@@ -7,6 +7,7 @@
*/
namespace Piwik\Plugins\Login;
+use Piwik\Container\StaticContainer;
use Piwik\Date;
use Piwik\Piwik;
use Piwik\Session\SessionNamespace;
@@ -37,6 +38,34 @@ class PasswordVerifier
return new SessionNamespace('Login');
}
+ public function isPasswordCorrect($userLogin, $password)
+ {
+ /**
+ * @ignore
+ * @internal
+ */
+ Piwik::postEvent('Login.beforeLoginCheckAllowed');
+
+ /** @var \Piwik\Auth $authAdapter */
+ $authAdapter = StaticContainer::get('Piwik\Auth');
+ $authAdapter->setLogin($userLogin);
+ $authAdapter->setPasswordHash(null);// ensure authentication happens on password
+ $authAdapter->setPassword($password);
+ $authAdapter->setTokenAuth(null);// ensure authentication happens on password
+ $authResult = $authAdapter->authenticate();
+
+ if ($authResult->wasAuthenticationSuccessful()) {
+ return true;
+ }
+
+ /**
+ * @ignore
+ * @internal
+ */
+ Piwik::postEvent('Login.recordFailedLoginAttempt');
+ return false;
+ }
+
public function hasPasswordVerifyBeenRequested()
{
$sessionNamespace = $this->getLoginSession();
diff --git a/plugins/Login/Security/BruteForceDetection.php b/plugins/Login/Security/BruteForceDetection.php
new file mode 100644
index 0000000000..7337257483
--- /dev/null
+++ b/plugins/Login/Security/BruteForceDetection.php
@@ -0,0 +1,134 @@
+<?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\Date;
+use Piwik\Db;
+use Piwik\Plugins\Login\SystemSettings;
+
+class BruteForceDetection {
+
+ private $minutesTimeRange;
+ private $maxLogAttempts;
+
+ private $table = 'brute_force_log';
+ private $tablePrefixed = '';
+
+ /**
+ * @var SystemSettings
+ */
+ private $settings;
+
+ public function __construct(SystemSettings $systemSettings)
+ {
+ $this->tablePrefixed = Common::prefixTable($this->table);
+ $this->settings = $systemSettings;
+ $this->minutesTimeRange = $systemSettings->loginAttemptsTimeRange->getValue();
+ $this->maxLogAttempts = $systemSettings->maxFailedLoginsPerMinutes->getValue();
+ }
+
+ public function isEnabled()
+ {
+ return $this->settings->enableBruteForceDetection->getValue();
+ }
+
+ public function addFailedAttempt($ipAddress)
+ {
+ $now = $this->getNow()->getDatetime();
+ $db = Db::get();
+ $db->query('INSERT INTO '.$this->tablePrefixed.' (ip_address, attempted_at) VALUES(?,?)', array($ipAddress, $now));
+ }
+
+ 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();
+ }
+} \ No newline at end of file
diff --git a/plugins/Login/SystemSettings.php b/plugins/Login/SystemSettings.php
new file mode 100644
index 0000000000..52da3225e4
--- /dev/null
+++ b/plugins/Login/SystemSettings.php
@@ -0,0 +1,135 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\Login;
+
+use Piwik\Network\IP;
+use Piwik\Piwik;
+use Piwik\Settings\Setting;
+use Piwik\Settings\FieldConfig;
+use Piwik\Validators\IpRanges;
+
+/**
+ * Defines Settings for Login.
+ */
+class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings
+{
+ /** @var Setting */
+ public $enableBruteForceDetection;
+
+ /** @var Setting */
+ public $whitelisteBruteForceIps;
+
+ /** @var Setting */
+ public $blacklistedBruteForceIps;
+
+ /** @var Setting */
+ public $maxFailedLoginsPerMinutes;
+
+ /** @var Setting */
+ public $loginAttemptsTimeRange;
+
+ protected function init()
+ {
+ $this->enableBruteForceDetection = $this->createEnableBruteForceDetection();
+ $this->maxFailedLoginsPerMinutes = $this->createMaxFailedLoginsPerMinutes();
+ $this->loginAttemptsTimeRange = $this->createLoginAttemptsTimeRange();
+ $this->blacklistedBruteForceIps = $this->createBlacklistedBruteForceIps();
+ $this->whitelisteBruteForceIps = $this->createWhitelisteBruteForceIps();
+ }
+
+ private function createEnableBruteForceDetection()
+ {
+ return $this->makeSetting('enableBruteForceDetection', $default = true, FieldConfig::TYPE_BOOL, function (FieldConfig $field) {
+ $field->title = Piwik::translate('Login_SettingBruteForceEnable');
+ $field->description = Piwik::translate('Login_SettingBruteForceEnableHelp');
+ $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX;
+ });
+ }
+
+ private function createWhitelisteBruteForceIps()
+ {
+ return $this->makeSetting('whitelisteBruteForceIps', array(), FieldConfig::TYPE_ARRAY, function (FieldConfig $field) {
+ $field->title = Piwik::translate('Login_SettingBruteForceWhitelistIp');
+ $field->uiControl = FieldConfig::UI_CONTROL_TEXTAREA;
+ $field->description = Piwik::translate('Login_HelpIpRange', array('1.2.3.4/24', '1.2.3.*', '1.2.*.*'));
+ $field->validators[] = new IpRanges();
+ $field->transform = function ($value) {
+ if (empty($value)) {
+ return array();
+ }
+
+ $ips = array_map('trim', $value);
+ $ips = array_filter($ips, 'strlen');
+ return $ips;
+ };
+ });
+ }
+
+ private function createBlacklistedBruteForceIps()
+ {
+ return $this->makeSetting('blacklistedBruteForceIps', array(), FieldConfig::TYPE_ARRAY, function (FieldConfig $field) {
+ $field->title = Piwik::translate('Login_SettingBruteForceBlacklistIp');
+ $field->uiControl = FieldConfig::UI_CONTROL_TEXTAREA;
+ $field->description = Piwik::translate('Login_HelpIpRange', array('1.2.3.4/24', '1.2.3.*', '1.2.*.*'));
+ $field->validators[] = new IpRanges();
+ $field->transform = function ($value) {
+ if (empty($value)) {
+ return array();
+ }
+
+ $ips = array_map('trim', $value);
+ $ips = array_filter($ips, 'strlen');
+ return $ips;
+ };
+ });
+ }
+
+ private function createMaxFailedLoginsPerMinutes()
+ {
+ return $this->makeSetting('maxAllowedRetries', 20, FieldConfig::TYPE_INT, function (FieldConfig $field) {
+ $field->title = Piwik::translate('Login_SettingBruteForceMaxFailedLogins');
+ $field->uiControl = FieldConfig::UI_CONTROL_TEXT;
+ $field->description = Piwik::translate('Login_SettingBruteForceMaxFailedLoginsHelp');
+ });
+ }
+
+ private function createLoginAttemptsTimeRange()
+ {
+ return $this->makeSetting('allowedRetriesTimeRange', 60, FieldConfig::TYPE_INT, function (FieldConfig $field) {
+ $field->title = Piwik::translate('Login_SettingBruteForceTimeRange');
+ $field->description = Piwik::translate('Login_SettingBruteForceTimeRangeHelp');
+ $field->uiControl = FieldConfig::UI_CONTROL_TEXT;
+ });
+ }
+
+ public function isWhitelistedIp($ipAddress)
+ {
+ return $this->isIpInList($ipAddress, $this->whitelisteBruteForceIps->getValue());
+ }
+
+ public function isBlacklistedIp($ipAddress)
+ {
+ return $this->isIpInList($ipAddress, $this->blacklistedBruteForceIps->getValue());
+ }
+
+ private function isIpInList($ipAddress, $ips)
+ {
+ if (empty($ipAddress)) {
+ return false;
+ }
+
+ $ip = IP::fromStringIP($ipAddress);
+
+ if (empty($ips)) {
+ return false;
+ }
+
+ return $ip->isInRanges($ips);
+ }
+}
diff --git a/plugins/Login/Tasks.php b/plugins/Login/Tasks.php
new file mode 100644
index 0000000000..9cdcaaa414
--- /dev/null
+++ b/plugins/Login/Tasks.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\Login;
+
+use Piwik\Plugins\Login\Security\BruteForceDetection;
+
+class Tasks extends \Piwik\Plugin\Tasks
+{
+ /**
+ * @var BruteForceDetection
+ */
+ private $bruteForceDetection;
+
+ public function __construct(BruteForceDetection $bruteForceDetection)
+ {
+ $this->bruteForceDetection = $bruteForceDetection;
+ }
+
+ public function schedule()
+ {
+ $this->daily('cleanupBruteForceLogs');
+ }
+
+ public function cleanupBruteForceLogs()
+ {
+ $this->bruteForceDetection->cleanupOldEntries();
+ }
+
+}
diff --git a/plugins/Login/config/test.php b/plugins/Login/config/test.php
new file mode 100644
index 0000000000..cf83aaabdb
--- /dev/null
+++ b/plugins/Login/config/test.php
@@ -0,0 +1,36 @@
+<?php
+return array(
+ 'Piwik\Plugins\Login\SystemSettings' => DI\decorate(function ($settings, \Interop\Container\ContainerInterface $c) {
+ /** @var \Piwik\Plugins\Login\SystemSettings $settings */
+
+ \Piwik\Access::doAsSuperUser(function () use ($settings, $c) {
+ if ($c->get('test.vars.bruteForceBlockIps')) {
+ $settings->blacklistedBruteForceIps->setValue(array('10.2.3.4'));
+ } elseif (\Piwik\SettingsPiwik::isPiwikInstalled()) {
+ $settings->blacklistedBruteForceIps->setValue(array());
+ }
+ });
+
+ return $settings;
+ }),
+ 'Piwik\Plugins\Login\Security\BruteForceDetection' => DI\decorate(function ($detection, \Interop\Container\ContainerInterface $c) {
+ /** @var \Piwik\Plugins\Login\Security\BruteForceDetection $detection */
+
+ if ($c->get('test.vars.bruteForceBlockIps')) {
+ for ($i = 0; $i < 30; $i++) {
+ // we block a random IP
+ $detection->addFailedAttempt('10.55.66.77');
+ }
+ } else if ($c->get('test.vars.bruteForceBlockThisIp')) {
+ for ($i = 0; $i < 30; $i++) {
+ // we block this IP
+ $detection->addFailedAttempt(\Piwik\IP::getIpFromHeader());
+ }
+ } elseif (\Piwik\SettingsPiwik::isPiwikInstalled()) {
+ // prevent tests from blocking other tests
+ $detection->deleteAll();
+ }
+
+ return $detection;
+ }),
+); \ No newline at end of file
diff --git a/plugins/Login/javascripts/bruteforcelog.js b/plugins/Login/javascripts/bruteforcelog.js
new file mode 100644
index 0000000000..079ed27277
--- /dev/null
+++ b/plugins/Login/javascripts/bruteforcelog.js
@@ -0,0 +1,26 @@
+/*!
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+(function ($) {
+
+ window.bruteForceLog = {
+ unblockAllIps: function () {
+ piwikHelper.modalConfirm('#confirmUnblockAllIps', {yes: function () {
+ var ajaxRequest = new ajaxHelper();
+ ajaxRequest.addParams({
+ module: 'API',
+ method: 'Login.unblockBruteForceIPs',
+ }, 'get');
+ ajaxRequest.setCallback(
+ function (response) {
+ piwikHelper.reload();
+ }
+ );
+ ajaxRequest.send();
+ }});
+ }
+ };
+}(jQuery));
diff --git a/plugins/Login/lang/en.json b/plugins/Login/lang/en.json
index 314bb38293..c257f5a8c2 100644
--- a/plugins/Login/lang/en.json
+++ b/plugins/Login/lang/en.json
@@ -1,5 +1,6 @@
{
"Login": {
+ "BruteForceLog": "Brute Force Log",
"ConfirmationLinkSent": "A confirmation link has been sent to your inbox. Check your e-mail and visit this link to authorize your password change request.",
"ContactAdmin": "Possible reason: your host may have disabled the mail() function. <br \/>Please contact your Matomo administrator.",
"ExceptionInvalidSuperUserAccessAuthenticationMethod": "A user with Super User access cannot be authenticated using the '%s' mechanism.",
@@ -10,6 +11,21 @@
"InvalidUsernameEmail": "Invalid username or e-mail address.",
"LogIn": "Sign in",
"LoginOrEmail": "Username or Email",
+ "HelpIpRange": "Enter one IP address or one IP range per line. You can use CIDR notation eg. %1$s or you can use wildcards, eg. %2$s or %3$s",
+ "SettingBruteForceEnable": "Enable Brute Force Detection",
+ "SettingBruteForceEnableHelp": "Brute Force Detection is an important security feature used to protect your data from unauthorized access. Instead of allowing any user to try thousands, or millions of password combinations within a very short time, it will only allow a specific amount of failed logins within a short period of time. If too many failed logins occur in that time range, the user won't be able to log in until some time has passed. Please note that if an IP is blocked, every user that uses that IP will be blocked from logging in as well.",
+ "SettingBruteForceWhitelistIp": "Never block these IPs from logging in",
+ "SettingBruteForceBlacklistIp": "Never block these IPs from logging in",
+ "SettingBruteForceMaxFailedLogins": "Number of allowed login retries within time range",
+ "SettingBruteForceMaxFailedLoginsHelp": "If more than this number of failed logins are recorded within the time range configured below, block the IP.",
+ "SettingBruteForceTimeRange": "Count login retries within this time range in minutes",
+ "SettingBruteForceTimeRangeHelp": "Enter a number in minutes.",
+ "LoginNotAllowedBecauseBlocked": "You are currently not allowed to log in because you had too many failed logins, try again later.",
+ "CurrentlyBlockedIPs": "Currently blocked IPs",
+ "IPsAlwaysBlocked": "These IPs are always blocked",
+ "UnblockAllIPs": "Unblock all currently blocked IPs",
+ "CurrentlyBlockedIPsUnblockInfo": "You can unblock IPs that are currently blocked so they can log in again in case they were falsely flagged and need to be able to log in again.",
+ "CurrentlyBlockedIPsUnblockConfirm": "Are you sure you want to unblock all currently blocked IPs?",
"LoginPasswordNotCorrect": "Wrong Username and password combination.",
"LostYourPassword": "Lost your password?",
"ChangeYourPassword": "Change your password",
diff --git a/plugins/Login/templates/bruteForceLog.twig b/plugins/Login/templates/bruteForceLog.twig
new file mode 100644
index 0000000000..da856790bf
--- /dev/null
+++ b/plugins/Login/templates/bruteForceLog.twig
@@ -0,0 +1,42 @@
+{% extends 'admin.twig' %}
+
+{% set title %}{{ 'Login_BruteForceLog'|translate }}{% endset %}
+
+{% block content %}
+
+ <div piwik-content-block content-title="{{ 'Login_CurrentlyBlockedIPs'|translate|e('html_attr') }}">
+ {% if blockedIps is empty %}
+ <p>{{ 'UserCountryMap_None'|translate }}</p>
+ {% else %}
+ <ul style="margin-left: 20px;">
+ {% for blockedIp in blockedIps %}
+ <li style="list-style: disc;">{{ blockedIp }}</li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+
+ {% if blockedIps is not empty %}
+ <p><br />{{ 'Login_CurrentlyBlockedIPsUnblockInfo'|translate }}</p>
+
+ <div>
+ <input type="button" class="btn" value="{{ 'Login_UnblockAllIPs'|translate }}" onclick="bruteForceLog.unblockAllIps();">
+ </div>
+
+ <div id="confirmUnblockAllIps" class="ui-confirm">
+ <h2>{{ 'Login_CurrentlyBlockedIPsUnblockConfirm'|translate }}</h2>
+ <input role="yes" type="button" value="{{ 'General_Yes'|translate }}"/>
+ <input role="no" type="button" value="{{ 'General_No'|translate }}"/>
+ </div>
+ {% endif %}
+
+ {% if blacklistedIps is not empty %}
+ <h3>{{ 'Login_IPsAlwaysBlocked'|translate }}</h3>
+ <ul style="margin-left: 20px;">
+ {% for blacklistedIp in blacklistedIps %}
+ <li style="list-style: disc;">{{ blacklistedIp }}</li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+ </div>
+
+{% endblock %}
diff --git a/plugins/Login/tests/Integration/APITest.php b/plugins/Login/tests/Integration/APITest.php
new file mode 100644
index 0000000000..76c112a071
--- /dev/null
+++ b/plugins/Login/tests/Integration/APITest.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\Login\tests\Integration;
+
+use Piwik\Container\StaticContainer;
+use \Piwik\Plugins\Login\API;
+use Piwik\Tests\Framework\Mock\FakeAccess;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group API
+ * @group APITest
+ */
+class APITest extends IntegrationTestCase
+{
+ /**
+ * @var API
+ */
+ private $api;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->api = API::getInstance();
+ }
+
+ /**
+ * @expectedException \Exception
+ * @expectedExceptionMessage checkUserHasSuperUserAccess
+ */
+ public function test_unblockBruteForceIPs_requiresSuperUser()
+ {
+ FakeAccess::clearAccess(false, array(1,2,3));
+ $this->api->unblockBruteForceIPs();
+ }
+
+ public function test_unblockBruteForceIPs_doesNotFailWhenNothingToRemove()
+ {
+ $this->api->unblockBruteForceIPs();
+ $this->assertTrue(true);
+ }
+
+ public function test_unblockBruteForceIPs_removesBlockedIps()
+ {
+ $bruteForce = StaticContainer::get('Piwik\Plugins\Login\Security\BruteForceDetection');
+ $bruteForce->addFailedAttempt('127.2.3.4');
+ for ($i = 0; $i < 22; $i++) {
+ $bruteForce->addFailedAttempt('127.2.3.5');
+ }
+ $this->assertCount(23, $bruteForce->getAll());
+ $this->api->unblockBruteForceIPs();
+ $this->assertCount(1, $bruteForce->getAll());
+ }
+
+ public function provideContainerConfig()
+ {
+ return array(
+ 'Piwik\Access' => new FakeAccess()
+ );
+ }
+}
diff --git a/plugins/Login/tests/Integration/Security/BruteForceDetectionTest.php b/plugins/Login/tests/Integration/Security/BruteForceDetectionTest.php
new file mode 100644
index 0000000000..d258d9d3d4
--- /dev/null
+++ b/plugins/Login/tests/Integration/Security/BruteForceDetectionTest.php
@@ -0,0 +1,260 @@
+<?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\tests\Integration\Security;
+
+use Piwik\Date;
+use Piwik\Plugins\Login\Security\BruteForceDetection;
+use Piwik\Plugins\Login\SystemSettings;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+class CustomBruteForceDetection extends BruteForceDetection {
+ /**
+ * @var Date
+ */
+ private $now;
+
+ public function setNow($now)
+ {
+ $this->now = $now;
+ }
+
+ public function getNow()
+ {
+ if (!isset($this->now)) {
+ return Date::factory('2018-09-23 12:40:10');
+ }
+
+ return $this->now;
+ }
+}
+
+/**
+ * @group Login
+ * @group BruteForceDetection
+ */
+class BruteForceDetectionTest extends IntegrationTestCase
+{
+ /**
+ * @var CustomBruteForceDetection
+ */
+ private $detection;
+
+ /**
+ * @var SystemSettings
+ */
+ private $settings;
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->settings = new SystemSettings();
+ $this->settings->loginAttemptsTimeRange->setValue(10);
+ $this->settings->maxFailedLoginsPerMinutes->setValue(5);
+ $this->settings->whitelisteBruteForceIps->setValue(array('10.99.99.99'));
+ $this->settings->blacklistedBruteForceIps->setValue(array('10.55.55.55'));
+ $this->detection = new CustomBruteForceDetection($this->settings);
+ }
+
+ public function test_isEnabled_isEnabledByDefault()
+ {
+ $this->assertTrue($this->detection->isEnabled());
+ }
+
+ public function test_addFailedAttempt_addsEntries()
+ {
+ $this->addFailedLoginInPast('127.0.0.1', 1);
+ $this->addFailedLoginInPast('2001:0db8:85a3:0000:0000:8a2e:0370:7334', 2);
+ $this->addFailedLoginInPast('10.1.2.3', 3);
+ $this->addFailedLoginInPast('2001:0db8:85a3:0000:0000:8a2e:0370:7334', 4);
+ $this->addFailedLoginInPast('10.1.2.3', 5);
+
+ $entries = $this->detection->getAll();
+ $expected = array (
+ array (
+ 'id_brute_force_log' => '1',
+ 'ip_address' => '127.0.0.1',
+ 'attempted_at' => '2018-09-23 12:39:10',
+ ),
+ array (
+ 'id_brute_force_log' => '2',
+ 'ip_address' => '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
+ 'attempted_at' => '2018-09-23 12:38:10',
+ ),
+ array (
+ 'id_brute_force_log' => '3',
+ 'ip_address' => '10.1.2.3',
+ 'attempted_at' => '2018-09-23 12:37:10',
+ ),
+ array (
+ 'id_brute_force_log' => '4',
+ 'ip_address' => '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
+ 'attempted_at' => '2018-09-23 12:36:10',
+ ),
+ array (
+ 'id_brute_force_log' => '5',
+ 'ip_address' => '10.1.2.3',
+ 'attempted_at' => '2018-09-23 12:35:10',
+ ),
+ );
+ $this->assertEquals($expected, $entries);
+ }
+
+ public function test_unblockIp_onlyRemovesRecentEntriesOfIp()
+ {
+ $now = $this->detection->getNow();
+ $this->addFailedLoginInPast('127.0.0.1',1);
+ $this->addFailedLoginInPast('10.1.2.3', 2); // should be deleted
+ $this->addFailedLoginInPast('10.1.2.3', 3); // should be deleted
+
+ // those should not be touched
+ $this->detection->setNow($now->subDay(20));
+ $this->detection->addFailedAttempt('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
+ $this->detection->addFailedAttempt('10.1.2.3');
+
+ $this->detection->setNow($now);
+ $this->assertCount(5, $this->detection->getAll());
+
+ $this->detection->unblockIp('10.1.2.3');
+
+ $entries = $this->detection->getAll();
+ $expected = array (
+ array (
+ 'id_brute_force_log' => '1',
+ 'ip_address' => '127.0.0.1',
+ 'attempted_at' => '2018-09-23 12:39:10',
+ ),
+ array (
+ 'id_brute_force_log' => '4',
+ 'ip_address' => '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
+ 'attempted_at' => '2018-09-03 12:40:10',
+ ),
+ array (
+ 'id_brute_force_log' => '5',
+ 'ip_address' => '10.1.2.3',
+ 'attempted_at' => '2018-09-03 12:40:10',
+ ),
+ );
+ $this->assertEquals($expected, $entries);
+ }
+
+ public function test_cleanupOldEntries_onlyRemovesOldEntries()
+ {
+ $now = $this->detection->getNow();
+ // these should be kept cause they are recent
+ $this->addFailedLoginInPast('127.0.0.1', 1);
+ $this->addFailedLoginInPast('10.1.2.3', 2);
+
+ $this->detection->setNow($now->subDay(5));
+ $this->detection->addFailedAttempt('10.1.2.6');
+
+ // those should be cleaned up
+ $this->detection->setNow($now->subDay(10));
+ $this->detection->addFailedAttempt('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
+ $this->detection->addFailedAttempt('10.1.2.4');
+
+ $this->detection->setNow($now);
+ $this->assertCount(5, $this->detection->getAll());
+
+ $this->detection->cleanupOldEntries();
+
+ $entries = $this->detection->getAll();
+ $expected = array (
+ array (
+ 'id_brute_force_log' => '1',
+ 'ip_address' => '127.0.0.1',
+ 'attempted_at' => '2018-09-23 12:39:10',
+ ),
+ array (
+ 'id_brute_force_log' => '2',
+ 'ip_address' => '10.1.2.3',
+ 'attempted_at' => '2018-09-23 12:38:10',
+ ),
+ array (
+ 'id_brute_force_log' => '3',
+ 'ip_address' => '10.1.2.6',
+ 'attempted_at' => '2018-09-18 12:40:10',
+ ),
+ );
+ $this->assertEquals($expected, $entries);
+ }
+
+ public function test_getCurrentlyBlockedIps_noIpBlockedWhenNonAdded()
+ {
+ $this->assertEquals(array(), $this->detection->getCurrentlyBlockedIps());
+ }
+
+ public function test_getCurrentlyBlockedIps_noIpBlockedWhenOnlyRecentOnesAdded()
+ {
+ $this->detection->addFailedAttempt('127.0.0.1');
+ $this->detection->addFailedAttempt('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
+ $this->detection->addFailedAttempt('10.1.2.3');
+ $this->detection->addFailedAttempt('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
+ $this->detection->addFailedAttempt('10.1.2.3');
+
+ $this->assertEquals(array(), $this->detection->getCurrentlyBlockedIps());
+ }
+
+ public function test_getCurrentlyBlockedIps_isAllowedToLogin_onlyBlockedWhenMaxAttemptsReached()
+ {
+ $this->addFailedLoginInPast('127.0.0.1', 1);
+ $this->addFailedLoginInPast('2001:0db8:85a3:0000:0000:8a2e:0370:7334', 2);
+ $this->addFailedLoginInPast('10.1.2.3', 3);
+ $this->addFailedLoginInPast('10.1.2.3', 5);
+ $this->addFailedLoginInPast('10.1.2.3', 7);
+ $this->addFailedLoginInPast('10.1.2.3', 9); // 4 logins per 10 minute allowed
+
+ // now we make sure more than 10 minutes ago there were heaps of entries and the user was actually blocked...
+ // but not anymore cause only last 10 min matters
+ $this->addFailedLoginInPast('10.1.2.3', 11);
+ $this->addFailedLoginInPast('10.1.2.3', 12);
+ for ($i = 0; $i < 20; $i++) {
+ $this->addFailedLoginInPast('10.1.2.3', 14);
+ }
+
+ $this->assertEquals(array(), $this->detection->getCurrentlyBlockedIps());
+ $this->assertTrue($this->detection->isAllowedToLogin('10.1.2.3'));
+ $this->assertTrue($this->detection->isAllowedToLogin('127.0.0.1'));
+
+ $this->detection->setNow($this->detection->getNow()->subPeriod(10, 'minute'));
+
+ // now we go 10 min back and the user will be blocked
+ $this->assertEquals(array('10.1.2.3'), $this->detection->getCurrentlyBlockedIps());
+ $this->assertFalse($this->detection->isAllowedToLogin('10.1.2.3'));
+ $this->assertTrue($this->detection->isAllowedToLogin('127.0.0.1'));
+ }
+
+ public function test_getCurrentlyBlockedIps_isAllowedToLogin_whitelistedIpCanAlwaysLoginAndIsNeverBlocked()
+ {
+ for ($i = 0; $i < 20; $i++) {
+ $this->addFailedLoginInPast('10.99.99.99', 1);
+ $this->addFailedLoginInPast('127.0.0.1', 1);
+ }
+
+ $this->assertEquals(array('127.0.0.1'), $this->detection->getCurrentlyBlockedIps());
+ $this->assertTrue($this->detection->isAllowedToLogin('10.99.99.99'));
+ $this->assertFalse($this->detection->isAllowedToLogin('127.0.0.1'));
+ }
+
+ public function test_getCurrentlyBlockedIps_isAllowedToLogin_blacklistedIpCanNeverLogIn_EvenWhenNoFailedAttempts()
+ {
+ $this->assertEquals(array(), $this->detection->getCurrentlyBlockedIps());
+ $this->assertEquals(array(), $this->detection->getAll());
+ $this->assertFalse($this->detection->isAllowedToLogin('10.55.55.55'));
+ }
+
+ private function addFailedLoginInPast($ipAddress, $minutes)
+ {
+ $now = $this->detection->getNow();
+ $this->detection->setNow($now->subPeriod($minutes, 'minute'));
+ $this->detection->addFailedAttempt($ipAddress);
+ $this->detection->setNow($now);
+ }
+
+}
diff --git a/plugins/Login/tests/Integration/SystemSettingsTest.php b/plugins/Login/tests/Integration/SystemSettingsTest.php
new file mode 100644
index 0000000000..8bf1a83afe
--- /dev/null
+++ b/plugins/Login/tests/Integration/SystemSettingsTest.php
@@ -0,0 +1,146 @@
+<?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\tests\Integration;
+
+use Piwik\Plugins\Login\SystemSettings;
+use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
+
+/**
+ * @group Login
+ * @group BruteForceDetection
+ */
+class SystemSettingsTest extends IntegrationTestCase
+{
+ /**
+ * @var SystemSettings
+ */
+ private $settings;
+
+ private $exampleIps = array(
+ '12.12.12.12/27',
+ '14.14.14.14',
+ '15.15.15.*',
+ '2001:db8::/40',
+ '2001:0db8:85a3:0000:0000:8a2e:0370:7334'
+ );
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $this->settings = new SystemSettings();
+ }
+
+ public function test_enableBruteForceDetection_isEnabledByDefault()
+ {
+ $this->assertTrue($this->settings->enableBruteForceDetection->getValue());
+ }
+
+ public function test_loginAttemptsTimeRange_hasCorrectDefaultValue()
+ {
+ $this->assertSame(60, $this->settings->loginAttemptsTimeRange->getValue());
+ }
+
+ public function test_maxFailedLoginsPerMinutes_hasCorrectDefaultValue()
+ {
+ $this->assertSame(20, $this->settings->maxFailedLoginsPerMinutes->getValue());
+ }
+
+ public function test_whitelisteBruteForceIps_hasNoIpWhitelisted()
+ {
+ $this->assertSame([], $this->settings->whitelisteBruteForceIps->getValue());
+ }
+
+ public function test_whitelisteBruteForceIps_CanSuccessfullySetVariousIpsAndRanges()
+ {
+ $this->settings->whitelisteBruteForceIps->setValue($this->exampleIps);
+ $this->assertSame($this->exampleIps, $this->settings->whitelisteBruteForceIps->getValue());
+ }
+
+ /**
+ * @expectedException \Exception
+ * @expectedExceptionMessage SitesManager_ExceptionInvalidIPFormat
+ */
+ public function test_whitelisteBruteForceIps_failsWhenContainsInvalidValue()
+ {
+ $this->settings->whitelisteBruteForceIps->setValue(array(
+ '127.0.0.1', 'foobar'
+ ));
+ }
+
+ public function test_isWhitelistedIp_doesNotWhitelistAnyIpsByDefault()
+ {
+ $this->assertFalse($this->settings->isWhitelistedIp('127.0.0.1'));
+ }
+
+ /**
+ * @dataProvider getIpListedDataProvider
+ */
+ public function test_isWhitelistedIp_isIpInList($expected, $ip)
+ {
+ $this->settings->whitelisteBruteForceIps->setValue($this->exampleIps);
+ $this->assertSame($expected, $this->settings->isWhitelistedIp($ip));
+ $this->assertFalse($this->settings->isBlacklistedIp($ip));
+ }
+
+ public function test_blacklistedBruteForceIps_hasNoIpWhitelisted()
+ {
+ $this->assertSame([], $this->settings->blacklistedBruteForceIps->getValue());
+ }
+
+ public function test_blacklistedBruteForceIps_CanSuccessfullySetVariousIpsAndRanges()
+ {
+ $this->settings->blacklistedBruteForceIps->setValue($this->exampleIps);
+ $this->assertSame($this->exampleIps, $this->settings->blacklistedBruteForceIps->getValue());
+ }
+
+ /**
+ * @expectedException \Exception
+ * @expectedExceptionMessage SitesManager_ExceptionInvalidIPFormat
+ */
+ public function test_blacklistedBruteForceIps_failsWhenContainsInvalidValue()
+ {
+ $this->settings->blacklistedBruteForceIps->setValue(array(
+ '127.0.0.1', 'foobar'
+ ));
+ }
+
+ /**
+ * @dataProvider getIpListedDataProvider
+ */
+ public function test_isBlacklistedIp_isIpInList($expected, $ip)
+ {
+ $this->settings->blacklistedBruteForceIps->setValue($this->exampleIps);
+ $this->assertSame($expected, $this->settings->isBlacklistedIp($ip));
+ $this->assertFalse($this->settings->isWhitelistedIp($ip));
+ }
+
+ public function getIpListedDataProvider()
+ {
+ return array(
+ array(true, '12.12.12.14'),
+ array(true, '12.12.12.31'),
+ array(true, '14.14.14.14'),
+ array(true, '15.15.15.123'),
+ array(true, '2001:0db8:85a3:0000:0000:8a2e:0370:7334'),
+
+ array(false, ''),
+ array(false, null),
+ array(false, '12.12.12.32'),
+ array(false, '14.14.14.12'),
+ array(false, '2001:0db8:85a3:0000:0000:8a2e:0370:7333'),
+ );
+ }
+
+ public function test_isBlacklistedIp_doesNotWhitelistAnyIpsByDefault()
+ {
+ $this->assertFalse($this->settings->isBlacklistedIp('127.0.0.1'));
+ }
+
+}
diff --git a/plugins/Login/tests/UI/Login_spec.js b/plugins/Login/tests/UI/Login_spec.js
index 00349658c1..84bbc33190 100644
--- a/plugins/Login/tests/UI/Login_spec.js
+++ b/plugins/Login/tests/UI/Login_spec.js
@@ -11,7 +11,9 @@ describe("Login", function () {
this.timeout(0);
var md5Pass = "0adcc0d741277f74c64c8abab7330d1c", // md5("smarty-pants")
- formlessLoginUrl = "?module=Login&action=logme&login=oliverqueen&password=" + md5Pass;
+ formlessLoginUrl = "?module=Login&action=logme&login=oliverqueen&password=" + md5Pass,
+ bruteForceLogUrl = "?module=Login&action=bruteForceLog",
+ apiAuthUrl = "?module=API&method=UsersManager.getTokenAuth&format=json&userLogin=ovliverqueen&md5Password=" + md5Pass;
before(function () {
testEnvironment.testUseMockAuth = 0;
@@ -19,10 +21,38 @@ describe("Login", function () {
testEnvironment.save();
});
+ beforeEach(function () {
+ testEnvironment.testUseMockAuth = 0;
+ testEnvironment.queryParamOverride = {date: "2012-01-01", period: "year"};
+ testEnvironment.save();
+ });
+
after(function () {
testEnvironment.testUseMockAuth = 1;
+ delete testEnvironment.bruteForceBlockIps;
+ delete testEnvironment.bruteForceBlockThisIp;
+ delete testEnvironment.queryParamOverride;
+ testEnvironment.save();
+ });
+
+ afterEach(function () {
+ testEnvironment.testUseMockAuth = 1;
+ delete testEnvironment.bruteForceBlockIps;
+ delete testEnvironment.bruteForceBlockThisIp;
+ delete testEnvironment.queryParamOverride;
+ testEnvironment.save();
+ });
+
+ it("should show error when trying to log in through login form", function (done) {
+ testEnvironment.testUseMockAuth = 0;
+ testEnvironment.bruteForceBlockThisIp = 1;
+ delete testEnvironment.bruteForceBlockIps;
delete testEnvironment.queryParamOverride;
testEnvironment.save();
+
+ expect.screenshot("bruteforcelog_blockedlogin").to.be.capture(function (page) {
+ page.load("");
+ }, done);
});
it("should load correctly", function (done) {
@@ -119,4 +149,53 @@ describe("Login", function () {
page.load('');
}, done);
});
+
+ it("should show brute force log url when there are no entries", function (done) {
+ testEnvironment.testUseMockAuth = 1;
+ delete testEnvironment.queryParamOverride;
+ delete testEnvironment.bruteForceBlockThisIp;
+ delete testEnvironment.bruteForceBlockIps;
+ testEnvironment.overrideConfig('General', 'login_whitelist_ip', []);
+ testEnvironment.save();
+
+ expect.screenshot("bruteforcelog_noentries").to.be.capture(function (page) {
+ page.load(bruteForceLogUrl);
+ }, done);
+ });
+
+ it("should show brute force log url when there are entries", function (done) {
+ testEnvironment.testUseMockAuth = 1;
+ testEnvironment.bruteForceBlockIps = 1;
+ delete testEnvironment.bruteForceBlockThisIp;
+ delete testEnvironment.queryParamOverride;
+ testEnvironment.save();
+
+ expect.screenshot("bruteforcelog_withentries").to.be.capture(function (page) {
+ page.load(bruteForceLogUrl);
+ }, done);
+ });
+
+ it("should show error when trying to attempt a log in through API", function (done) {
+ testEnvironment.testUseMockAuth = 1;
+ testEnvironment.bruteForceBlockThisIp = 1;
+ delete testEnvironment.bruteForceBlockIps;
+ delete testEnvironment.queryParamOverride;
+ testEnvironment.save();
+
+ expect.screenshot("bruteforcelog_blockedapi").to.be.capture(function (page) {
+ page.load(apiAuthUrl);
+ }, done);
+ });
+
+ it("should show error when trying to log in through logme", function (done) {
+ testEnvironment.testUseMockAuth = 0;
+ testEnvironment.bruteForceBlockThisIp = 1;
+ delete testEnvironment.bruteForceBlockIps;
+ delete testEnvironment.queryParamOverride;
+ testEnvironment.save();
+
+ expect.screenshot("bruteforcelog_blockedlogme").to.be.capture(function (page) {
+ page.load(formlessLoginUrl);
+ }, done);
+ });
}); \ No newline at end of file
diff --git a/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedapi.png b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedapi.png
new file mode 100644
index 0000000000..2a5eb11509
--- /dev/null
+++ b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedapi.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1af35b413e9e473265cea9a087881ae11381c5ef48396854f4a638882e546d1d
+size 8980
diff --git a/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedlogin.png b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedlogin.png
new file mode 100644
index 0000000000..88958e2c47
--- /dev/null
+++ b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedlogin.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8593ba93dbb91fdc1bdad14635707d9b6b06b0344a6c931b4a55ebe3ee33dccb
+size 51402
diff --git a/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedlogme.png b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedlogme.png
new file mode 100644
index 0000000000..88958e2c47
--- /dev/null
+++ b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_blockedlogme.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8593ba93dbb91fdc1bdad14635707d9b6b06b0344a6c931b4a55ebe3ee33dccb
+size 51402
diff --git a/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_noentries.png b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_noentries.png
new file mode 100644
index 0000000000..70bff45cd1
--- /dev/null
+++ b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_noentries.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7c5beced282043e1249bc18867483839161f3d6721f275ed78f882538d2b4c38
+size 88777
diff --git a/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_withentries.png b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_withentries.png
new file mode 100644
index 0000000000..728363b0d3
--- /dev/null
+++ b/plugins/Login/tests/UI/expected-screenshots/Login_bruteforcelog_withentries.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b31195fd9fb309fd87b71946b6390669e767dced12ae33cff84b00ae568cced6
+size 107278