--- /dev/null
+<?php
+namespace Shaarli;
+
+/**
+ * User login management
+ */
+class LoginManager
+{
+ protected $globals = [];
+ protected $configManager = null;
+ protected $banFile = '';
+
+ /**
+ * Constructor
+ *
+ * @param array $globals The $GLOBALS array (reference)
+ * @param ConfigManager $configManager Configuration Manager instance.
+ */
+ public function __construct(& $globals, $configManager)
+ {
+ $this->globals = &$globals;
+ $this->configManager = $configManager;
+ $this->banFile = $this->configManager->get('resource.ban_file', 'data/ipbans.php');
+ $this->readBanFile();
+ }
+
+ /**
+ * Read a file containing banned IPs
+ */
+ protected function readBanFile()
+ {
+ if (! file_exists($this->banFile)) {
+ return;
+ }
+ include $this->banFile;
+ }
+
+ /**
+ * Write the banned IPs to a file
+ */
+ protected function writeBanFile()
+ {
+ if (! array_key_exists('IPBANS', $this->globals)) {
+ return;
+ }
+ file_put_contents(
+ $this->banFile,
+ "<?php\n\$GLOBALS['IPBANS']=" . var_export($this->globals['IPBANS'], true) . ";\n?>"
+ );
+ }
+
+ /**
+ * Handle a failed login and ban the IP after too many failed attempts
+ *
+ * @param array $server The $_SERVER array
+ */
+ public function handleFailedLogin($server)
+ {
+ $ip = $server['REMOTE_ADDR'];
+ $trusted = $this->configManager->get('security.trusted_proxies', []);
+
+ if (in_array($ip, $trusted)) {
+ $ip = getIpAddressFromProxy($server, $trusted);
+ if (! $ip) {
+ // the IP is behind a trusted forward proxy, but is not forwarded
+ // in the HTTP headers, so we do nothing
+ return;
+ }
+ }
+
+ // increment the fail count for this IP
+ if (isset($this->globals['IPBANS']['FAILURES'][$ip])) {
+ $this->globals['IPBANS']['FAILURES'][$ip]++;
+ } else {
+ $this->globals['IPBANS']['FAILURES'][$ip] = 1;
+ }
+
+ if ($this->globals['IPBANS']['FAILURES'][$ip] >= $this->configManager->get('security.ban_after')) {
+ $this->globals['IPBANS']['BANS'][$ip] = time() + $this->configManager->get('security.ban_duration', 1800);
+ logm(
+ $this->configManager->get('resource.log'),
+ $server['REMOTE_ADDR'],
+ 'IP address banned from login'
+ );
+ }
+ $this->writeBanFile();
+ }
+
+ /**
+ * Handle a successful login
+ *
+ * @param array $server The $_SERVER array
+ */
+ public function handleSuccessfulLogin($server)
+ {
+ $ip = $server['REMOTE_ADDR'];
+ // FIXME unban when behind a trusted proxy?
+
+ unset($this->globals['IPBANS']['FAILURES'][$ip]);
+ unset($this->globals['IPBANS']['BANS'][$ip]);
+
+ $this->writeBanFile();
+ }
+
+ /**
+ * Check if the user can login from this IP
+ *
+ * @param array $server The $_SERVER array
+ *
+ * @return bool true if the user is allowed to login
+ */
+ public function canLogin($server)
+ {
+ $ip = $server['REMOTE_ADDR'];
+
+ if (! isset($this->globals['IPBANS']['BANS'][$ip])) {
+ // the user is not banned
+ return true;
+ }
+
+ if ($this->globals['IPBANS']['BANS'][$ip] > time()) {
+ // the user is still banned
+ return false;
+ }
+
+ // the ban has expired, the user can attempt to log in again
+ logm($this->configManager->get('resource.log'), $server['REMOTE_ADDR'], 'Ban lifted.');
+ unset($this->globals['IPBANS']['FAILURES'][$ip]);
+ unset($this->globals['IPBANS']['BANS'][$ip]);
+
+ $this->writeBanFile();
+ return true;
+ }
+}
use \Shaarli\Languages;
use \Shaarli\ThemeUtils;
use \Shaarli\Config\ConfigManager;
+use \Shaarli\LoginManager;
use \Shaarli\SessionManager;
// Ensure the PHP version is supported
}
$conf = new ConfigManager();
+$loginManager = new LoginManager($GLOBALS, $conf);
$sessionManager = new SessionManager($_SESSION, $conf);
// LC_MESSAGES isn't defined without php-intl, in this case use LC_COLLATE locale instead.
setcookie('shaarli_staySignedIn', FALSE, 0, WEB_PATH);
}
-
-// ------------------------------------------------------------------------------------------
-// Brute force protection system
-// Several consecutive failed logins will ban the IP address for 30 minutes.
-if (!is_file($conf->get('resource.ban_file', 'data/ipbans.php'))) {
- // FIXME! globals
- file_put_contents(
- $conf->get('resource.ban_file', 'data/ipbans.php'),
- "<?php\n\$GLOBALS['IPBANS']=".var_export(array('FAILURES'=>array(),'BANS'=>array()),true).";\n?>"
- );
-}
-include $conf->get('resource.ban_file', 'data/ipbans.php');
-/**
- * Signal a failed login. Will ban the IP if too many failures:
- *
- * @param ConfigManager $conf Configuration Manager instance.
- */
-function ban_loginFailed($conf)
-{
- $ip = $_SERVER['REMOTE_ADDR'];
- $trusted = $conf->get('security.trusted_proxies', array());
- if (in_array($ip, $trusted)) {
- $ip = getIpAddressFromProxy($_SERVER, $trusted);
- if (!$ip) {
- return;
- }
- }
- $gb = $GLOBALS['IPBANS'];
- if (! isset($gb['FAILURES'][$ip])) {
- $gb['FAILURES'][$ip]=0;
- }
- $gb['FAILURES'][$ip]++;
- if ($gb['FAILURES'][$ip] > ($conf->get('security.ban_after') - 1))
- {
- $gb['BANS'][$ip] = time() + $conf->get('security.ban_after', 1800);
- logm($conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], 'IP address banned from login');
- }
- $GLOBALS['IPBANS'] = $gb;
- file_put_contents(
- $conf->get('resource.ban_file', 'data/ipbans.php'),
- "<?php\n\$GLOBALS['IPBANS']=".var_export($gb,true).";\n?>"
- );
-}
-
-/**
- * Signals a successful login. Resets failed login counter.
- *
- * @param ConfigManager $conf Configuration Manager instance.
- */
-function ban_loginOk($conf)
-{
- $ip = $_SERVER['REMOTE_ADDR'];
- $gb = $GLOBALS['IPBANS'];
- unset($gb['FAILURES'][$ip]); unset($gb['BANS'][$ip]);
- $GLOBALS['IPBANS'] = $gb;
- file_put_contents(
- $conf->get('resource.ban_file', 'data/ipbans.php'),
- "<?php\n\$GLOBALS['IPBANS']=".var_export($gb,true).";\n?>"
- );
-}
-
-/**
- * Checks if the user CAN login. If 'true', the user can try to login.
- *
- * @param ConfigManager $conf Configuration Manager instance.
- *
- * @return bool: true if the user is allowed to login.
- */
-function ban_canLogin($conf)
-{
- $ip=$_SERVER["REMOTE_ADDR"]; $gb=$GLOBALS['IPBANS'];
- if (isset($gb['BANS'][$ip]))
- {
- // User is banned. Check if the ban has expired:
- if ($gb['BANS'][$ip]<=time())
- { // Ban expired, user can try to login again.
- logm($conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], 'Ban lifted.');
- unset($gb['FAILURES'][$ip]); unset($gb['BANS'][$ip]);
- file_put_contents(
- $conf->get('resource.ban_file', 'data/ipbans.php'),
- "<?php\n\$GLOBALS['IPBANS']=".var_export($gb,true).";\n?>"
- );
- return true; // Ban has expired, user can login.
- }
- return false; // User is banned.
- }
- return true; // User is not banned.
-}
-
// ------------------------------------------------------------------------------------------
// Process login form: Check if login/password is correct.
if (isset($_POST['login']))
{
- if (!ban_canLogin($conf)) die(t('I said: NO. You are banned for the moment. Go away.'));
+ if (! $loginManager->canLogin($_SERVER)) {
+ die(t('I said: NO. You are banned for the moment. Go away.'));
+ }
if (isset($_POST['password'])
&& $sessionManager->checkToken($_POST['token'])
&& (check_auth($_POST['login'], $_POST['password'], $conf))
- ) { // Login/password is OK.
- ban_loginOk($conf);
+ ) {
+ // Login/password is OK.
+ $loginManager->handleSuccessfulLogin($_SERVER);
+
// If user wants to keep the session cookie even after the browser closes:
- if (!empty($_POST['longlastingsession']))
- {
+ if (!empty($_POST['longlastingsession'])) {
$_SESSION['longlastingsession'] = 31536000; // (31536000 seconds = 1 year)
$expiration = time() + $_SESSION['longlastingsession']; // calculate relative cookie expiration (1 year from now)
setcookie('shaarli_staySignedIn', STAY_SIGNED_IN_TOKEN, $expiration, WEB_PATH);
}
}
header('Location: ?'); exit;
- }
- else
- {
- ban_loginFailed($conf);
+ } else {
+ $loginManager->handleFailedLogin($_SERVER);
$redir = '&username='. urlencode($_POST['login']);
if (isset($_GET['post'])) {
$redir .= '&post=' . urlencode($_GET['post']);
* @param LinkDB $LINKSDB
* @param History $history instance
* @param SessionManager $sessionManager SessionManager instance
+ * @param LoginManager $loginManager LoginManager instance
*/
-function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager)
+function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $loginManager)
{
$updater = new Updater(
read_updates_file($conf->get('resource.updates')),
$PAGE->assign('returnurl',(isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']):''));
// add default state of the 'remember me' checkbox
$PAGE->assign('remember_user_default', $conf->get('privacy.remember_user_default'));
+ $PAGE->assign('user_can_login', $loginManager->canLogin($_SERVER));
$PAGE->renderPage('loginform');
exit;
}
if ($response->getStatusCode() == 404 && strpos($_SERVER['REQUEST_URI'], '/api/v1') === false) {
// We use UTF-8 for proper international characters handling.
header('Content-Type: text/html; charset=utf-8');
- renderPage($conf, $pluginManager, $linkDb, $history, $sessionManager);
+ renderPage($conf, $pluginManager, $linkDb, $history, $sessionManager, $loginManager);
} else {
$app->respond($response);
}
--- /dev/null
+<?php
+namespace Shaarli;
+
+require_once 'tests/utils/FakeConfigManager.php';
+use \PHPUnit\Framework\TestCase;
+
+/**
+ * Test coverage for LoginManager
+ */
+class LoginManagerTest extends TestCase
+{
+ protected $configManager = null;
+ protected $loginManager = null;
+ protected $banFile = 'sandbox/ipbans.php';
+ protected $logFile = 'sandbox/shaarli.log';
+ protected $globals = [];
+ protected $ipAddr = '127.0.0.1';
+ protected $server = [];
+ protected $trustedProxy = '10.1.1.100';
+
+ /**
+ * Prepare or reset test resources
+ */
+ public function setUp()
+ {
+ if (file_exists($this->banFile)) {
+ unlink($this->banFile);
+ }
+
+ $this->configManager = new \FakeConfigManager([
+ 'resource.ban_file' => $this->banFile,
+ 'resource.log' => $this->logFile,
+ 'security.ban_after' => 4,
+ 'security.ban_duration' => 3600,
+ 'security.trusted_proxies' => [$this->trustedProxy],
+ ]);
+
+ $this->globals = &$GLOBALS;
+ unset($this->globals['IPBANS']);
+
+ $this->loginManager = new LoginManager($this->globals, $this->configManager);
+ $this->server['REMOTE_ADDR'] = $this->ipAddr;
+ }
+
+ /**
+ * Wipe test resources
+ */
+ public function tearDown()
+ {
+ unset($this->globals['IPBANS']);
+ }
+
+ /**
+ * Instantiate a LoginManager and load ban records
+ */
+ public function testReadBanFile()
+ {
+ file_put_contents(
+ $this->banFile,
+ "<?php\n\$GLOBALS['IPBANS']=array('FAILURES' => array('127.0.0.1' => 99));\n?>"
+ );
+ new LoginManager($this->globals, $this->configManager);
+ $this->assertEquals(99, $this->globals['IPBANS']['FAILURES']['127.0.0.1']);
+ }
+
+ /**
+ * Record a failed login attempt
+ */
+ public function testHandleFailedLogin()
+ {
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->assertEquals(1, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->assertEquals(2, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+ }
+
+ /**
+ * Record a failed login attempt - IP behind a trusted proxy
+ */
+ public function testHandleFailedLoginBehindTrustedProxy()
+ {
+ $server = [
+ 'REMOTE_ADDR' => $this->trustedProxy,
+ 'HTTP_X_FORWARDED_FOR' => $this->ipAddr,
+ ];
+ $this->loginManager->handleFailedLogin($server);
+ $this->assertEquals(1, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+
+ $this->loginManager->handleFailedLogin($server);
+ $this->assertEquals(2, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+ }
+
+ /**
+ * Record a failed login attempt - IP behind a trusted proxy but not forwarded
+ */
+ public function testHandleFailedLoginBehindTrustedProxyNoIp()
+ {
+ $server = [
+ 'REMOTE_ADDR' => $this->trustedProxy,
+ ];
+ $this->loginManager->handleFailedLogin($server);
+ $this->assertFalse(isset($this->globals['IPBANS']['FAILURES'][$this->ipAddr]));
+
+ $this->loginManager->handleFailedLogin($server);
+ $this->assertFalse(isset($this->globals['IPBANS']['FAILURES'][$this->ipAddr]));
+ }
+
+ /**
+ * Record a failed login attempt and ban the IP after too many failures
+ */
+ public function testHandleFailedLoginBanIp()
+ {
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->assertEquals(1, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->assertEquals(2, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->assertEquals(3, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->assertEquals(4, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+ $this->assertFalse($this->loginManager->canLogin($this->server));
+
+ // handleFailedLogin is not supposed to be called at this point:
+ // - no login form should be displayed once an IP has been banned
+ // - yet this could happen when using custom templates / scripts
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->assertEquals(5, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+ $this->assertFalse($this->loginManager->canLogin($this->server));
+ }
+
+ /**
+ * Nothing to do
+ */
+ public function testHandleSuccessfulLogin()
+ {
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+
+ $this->loginManager->handleSuccessfulLogin($this->server);
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+ }
+
+ /**
+ * Erase failure records after successfully logging in from this IP
+ */
+ public function testHandleSuccessfulLoginAfterFailure()
+ {
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->loginManager->handleFailedLogin($this->server);
+ $this->assertEquals(2, $this->globals['IPBANS']['FAILURES'][$this->ipAddr]);
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+
+ $this->loginManager->handleSuccessfulLogin($this->server);
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+ $this->assertFalse(isset($this->globals['IPBANS']['FAILURES'][$this->ipAddr]));
+ $this->assertFalse(isset($this->globals['IPBANS']['BANS'][$this->ipAddr]));
+ }
+
+ /**
+ * The IP is not banned
+ */
+ public function testCanLoginIpNotBanned()
+ {
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+ }
+
+ /**
+ * The IP is banned
+ */
+ public function testCanLoginIpBanned()
+ {
+ // ban the IP for an hour
+ $this->globals['IPBANS']['FAILURES'][$this->ipAddr] = 10;
+ $this->globals['IPBANS']['BANS'][$this->ipAddr] = time() + 3600;
+
+ $this->assertFalse($this->loginManager->canLogin($this->server));
+ }
+
+ /**
+ * The IP is banned, and the ban duration is over
+ */
+ public function testCanLoginIpBanExpired()
+ {
+ // ban the IP for an hour
+ $this->globals['IPBANS']['FAILURES'][$this->ipAddr] = 10;
+ $this->globals['IPBANS']['BANS'][$this->ipAddr] = time() + 3600;
+ $this->assertFalse($this->loginManager->canLogin($this->server));
+
+ // lift the ban
+ $this->globals['IPBANS']['BANS'][$this->ipAddr] = time() - 3600;
+ $this->assertTrue($this->loginManager->canLogin($this->server));
+ }
+}
*/
class FakeConfigManager
{
- public static function get($key)
+ protected $values = [];
+
+ /**
+ * Initialize with test values
+ *
+ * @param array $values Initial values
+ */
+ public function __construct($values = [])
+ {
+ $this->values = $values;
+ }
+
+ /**
+ * Set a given value
+ *
+ * @param string $key Key of the value to set
+ * @param mixed $value Value to set
+ */
+ public function set($key, $value)
+ {
+ $this->values[$key] = $value;
+ }
+
+ /**
+ * Get a given configuration value
+ *
+ * @param string $key Index of the value to retrieve
+ *
+ * @return mixed The value if set, else the name of the key
+ */
+ public function get($key)
{
+ if (isset($this->values[$key])) {
+ return $this->values[$key];
+ }
return $key;
}
}
</head>
<body>
{include="page.header"}
-{if="!ban_canLogin($conf)"}
+{if="!$user_can_login"}
<div class="pure-g pure-alert pure-alert-error pure-alert-closable center">
<div class="pure-u-2-24"></div>
<div class="pure-u-20-24">
<html>
<head>{include="includes"}</head>
<body
-{if="ban_canLogin($conf)"}
+{if="$user_can_login"}
{if="empty($username)"}
onload="document.loginform.login.focus();"
{else}