Merge pull request #1008 from virtualtam/refactor/authentication

Refactor login / ban management
This commit is contained in:
VirtualTam 2018-02-05 18:16:32 +01:00 committed by GitHub
commit 91f17fc92a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 385 additions and 103 deletions

View file

@ -0,0 +1,134 @@
<?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;
}
}

116
index.php
View file

@ -78,6 +78,7 @@
use \Shaarli\Languages; use \Shaarli\Languages;
use \Shaarli\ThemeUtils; use \Shaarli\ThemeUtils;
use \Shaarli\Config\ConfigManager; use \Shaarli\Config\ConfigManager;
use \Shaarli\LoginManager;
use \Shaarli\SessionManager; use \Shaarli\SessionManager;
// Ensure the PHP version is supported // Ensure the PHP version is supported
@ -122,6 +123,7 @@
} }
$conf = new ConfigManager(); $conf = new ConfigManager();
$loginManager = new LoginManager($GLOBALS, $conf);
$sessionManager = new SessionManager($_SESSION, $conf); $sessionManager = new SessionManager($_SESSION, $conf);
// LC_MESSAGES isn't defined without php-intl, in this case use LC_COLLATE locale instead. // LC_MESSAGES isn't defined without php-intl, in this case use LC_COLLATE locale instead.
@ -293,108 +295,22 @@ function logout() {
setcookie('shaarli_staySignedIn', FALSE, 0, WEB_PATH); 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. // Process login form: Check if login/password is correct.
if (isset($_POST['login'])) 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']) if (isset($_POST['password'])
&& $sessionManager->checkToken($_POST['token']) && $sessionManager->checkToken($_POST['token'])
&& (check_auth($_POST['login'], $_POST['password'], $conf)) && (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 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) $_SESSION['longlastingsession'] = 31536000; // (31536000 seconds = 1 year)
$expiration = time() + $_SESSION['longlastingsession']; // calculate relative cookie expiration (1 year from now) $expiration = time() + $_SESSION['longlastingsession']; // calculate relative cookie expiration (1 year from now)
setcookie('shaarli_staySignedIn', STAY_SIGNED_IN_TOKEN, $expiration, WEB_PATH); setcookie('shaarli_staySignedIn', STAY_SIGNED_IN_TOKEN, $expiration, WEB_PATH);
@ -437,10 +353,8 @@ function ban_canLogin($conf)
} }
} }
header('Location: ?'); exit; header('Location: ?'); exit;
} } else {
else $loginManager->handleFailedLogin($_SERVER);
{
ban_loginFailed($conf);
$redir = '&username='. urlencode($_POST['login']); $redir = '&username='. urlencode($_POST['login']);
if (isset($_GET['post'])) { if (isset($_GET['post'])) {
$redir .= '&post=' . urlencode($_GET['post']); $redir .= '&post=' . urlencode($_GET['post']);
@ -684,8 +598,9 @@ function showLinkList($PAGE, $LINKSDB, $conf, $pluginManager) {
* @param LinkDB $LINKSDB * @param LinkDB $LINKSDB
* @param History $history instance * @param History $history instance
* @param SessionManager $sessionManager SessionManager 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( $updater = new Updater(
read_updates_file($conf->get('resource.updates')), read_updates_file($conf->get('resource.updates')),
@ -761,6 +676,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager)
$PAGE->assign('returnurl',(isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']):'')); $PAGE->assign('returnurl',(isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']):''));
// add default state of the 'remember me' checkbox // add default state of the 'remember me' checkbox
$PAGE->assign('remember_user_default', $conf->get('privacy.remember_user_default')); $PAGE->assign('remember_user_default', $conf->get('privacy.remember_user_default'));
$PAGE->assign('user_can_login', $loginManager->canLogin($_SERVER));
$PAGE->renderPage('loginform'); $PAGE->renderPage('loginform');
exit; exit;
} }
@ -2330,7 +2246,7 @@ function resizeImage($filepath)
if ($response->getStatusCode() == 404 && strpos($_SERVER['REQUEST_URI'], '/api/v1') === false) { if ($response->getStatusCode() == 404 && strpos($_SERVER['REQUEST_URI'], '/api/v1') === false) {
// We use UTF-8 for proper international characters handling. // We use UTF-8 for proper international characters handling.
header('Content-Type: text/html; charset=utf-8'); header('Content-Type: text/html; charset=utf-8');
renderPage($conf, $pluginManager, $linkDb, $history, $sessionManager); renderPage($conf, $pluginManager, $linkDb, $history, $sessionManager, $loginManager);
} else { } else {
$app->respond($response); $app->respond($response);
} }

199
tests/LoginManagerTest.php Normal file
View file

@ -0,0 +1,199 @@
<?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));
}
}

View file

@ -5,8 +5,41 @@
*/ */
class FakeConfigManager 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; return $key;
} }
} }

View file

@ -5,7 +5,7 @@
</head> </head>
<body> <body>
{include="page.header"} {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-g pure-alert pure-alert-error pure-alert-closable center">
<div class="pure-u-2-24"></div> <div class="pure-u-2-24"></div>
<div class="pure-u-20-24"> <div class="pure-u-20-24">

View file

@ -2,7 +2,7 @@
<html> <html>
<head>{include="includes"}</head> <head>{include="includes"}</head>
<body <body
{if="ban_canLogin($conf)"} {if="$user_can_login"}
{if="empty($username)"} {if="empty($username)"}
onload="document.loginform.login.focus();" onload="document.loginform.login.focus();"
{else} {else}