Add bruteforce protection

This commit is contained in:
Frederic Guillot 2015-08-01 12:14:22 -04:00
parent db69d5c429
commit db88a00d48
20 changed files with 405 additions and 43 deletions

View File

@ -1,3 +1,16 @@
Version 1.0.18 (unreleased)
---------------------------
New features:
* Add login bruteforce protection with captcha and account lockdown
* Add new api procedures: getDefaultTaskColor(), getDefaultTaskColors() and getColorList()
* Add user api access
Bug fixes:
* Wrong template name for subtasks tooltip due to previous refactoring
Version 1.0.17
--------------

View File

@ -26,7 +26,7 @@ class Auth extends Base
{
$this->container['dispatcher']->dispatch('api.bootstrap', new Event);
if ($username !== 'jsonrpc' && $this->authentication->authenticate($username, $password)) {
if ($username !== 'jsonrpc' && ! $this->authentication->hasCaptcha($username) && $this->authentication->authenticate($username, $password)) {
$this->checkProcedurePermission(true, $method);
$this->userSession->refresh($this->user->getByUsername($username));
}

View File

@ -2,6 +2,8 @@
namespace Controller;
use Gregwar\Captcha\CaptchaBuilder;
/**
* Authentication controller
*
@ -22,6 +24,7 @@ class Auth extends Base
}
$this->response->html($this->template->layout('auth/index', array(
'captcha' => isset($values['username']) && $this->authentication->hasCaptcha($values['username']),
'errors' => $errors,
'values' => $values,
'no_layout' => true,
@ -64,4 +67,19 @@ class Auth extends Base
$this->session->close();
$this->response->redirect($this->helper->url->to('auth', 'login'));
}
/**
* Display captcha image
*
* @access public
*/
public function captcha()
{
$this->response->contentType('image/jpeg');
$builder = new CaptchaBuilder;
$builder->build();
$this->session['captcha'] = $builder->getPhrase();
$builder->output();
}
}

View File

@ -17,7 +17,7 @@ class Acl extends Base
* @var array
*/
private $public_acl = array(
'auth' => array('login', 'check'),
'auth' => array('login', 'check', 'captcha'),
'task' => array('readonly'),
'board' => array('readonly'),
'webhook' => '*',

View File

@ -5,6 +5,7 @@ namespace Model;
use Core\Request;
use SimpleValidator\Validator;
use SimpleValidator\Validators;
use Gregwar\Captcha\CaptchaBuilder;
/**
* Authentication model
@ -75,17 +76,51 @@ class Authentication extends Base
*/
public function authenticate($username, $password)
{
// Try first the database auth and then LDAP if activated
if ($this->backend('database')->authenticate($username, $password)) {
if ($this->user->isLocked($username)) {
$this->container['logger']->error('Account locked: '.$username);
return false;
}
else if ($this->backend('database')->authenticate($username, $password)) {
$this->user->resetFailedLogin($username);
return true;
}
else if (LDAP_AUTH && $this->backend('ldap')->authenticate($username, $password)) {
$this->user->resetFailedLogin($username);
return true;
}
$this->handleFailedLogin($username);
return false;
}
/**
* Return true if the captcha must be shown
*
* @access public
* @param string $username
* @return boolean
*/
public function hasCaptcha($username)
{
return $this->user->getFailedLogin($username) >= BRUTEFORCE_CAPTCHA;
}
/**
* Handle failed login
*
* @access public
* @param string $username
*/
public function handleFailedLogin($username)
{
$this->user->incrementFailedLogin($username);
if ($this->user->getFailedLogin($username) >= BRUTEFORCE_LOCKDOWN) {
$this->container['logger']->critical('Locking account: '.$username);
$this->user->lock($username, BRUTEFORCE_LOCKDOWN_DURATION);
}
}
/**
* Validate user login form
*
@ -95,27 +130,12 @@ class Authentication extends Base
*/
public function validateForm(array $values)
{
$v = new Validator($values, array(
new Validators\Required('username', t('The username is required')),
new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
new Validators\Required('password', t('The password is required')),
));
$result = $v->execute();
$errors = $v->getErrors();
list($result, $errors) = $this->validateFormCredentials($values);
if ($result) {
if ($this->authenticate($values['username'], $values['password'])) {
// Setup the remember me feature
if (! empty($values['remember_me'])) {
$credentials = $this->backend('rememberMe')
->create($this->userSession->getId(), Request::getIpAddress(), Request::getUserAgent());
$this->backend('rememberMe')->writeCookie($credentials['token'], $credentials['sequence'], $credentials['expiration']);
}
if ($this->validateFormCaptcha($values) && $this->authenticate($values['username'], $values['password'])) {
$this->createRememberMeSession($values);
}
else {
$result = false;
@ -123,9 +143,62 @@ class Authentication extends Base
}
}
return array($result, $errors);
}
/**
* Validate credentials syntax
*
* @access public
* @param array $values Form values
* @return array $valid, $errors [0] = Success or not, [1] = List of errors
*/
public function validateFormCredentials(array $values)
{
$v = new Validator($values, array(
new Validators\Required('username', t('The username is required')),
new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
new Validators\Required('password', t('The password is required')),
));
return array(
$result,
$errors
$v->execute(),
$v->getErrors(),
);
}
/**
* Validate captcha
*
* @access public
* @param array $values Form values
* @return boolean
*/
public function validateFormCaptcha(array $values)
{
if ($this->hasCaptcha($values['username'])) {
$builder = new CaptchaBuilder;
$builder->setPhrase($this->session['captcha']);
return $builder->testPhrase(isset($values['captcha']) ? $values['captcha'] : '');
}
return true;
}
/**
* Create remember me session if necessary
*
* @access private
* @param array $values Form values
*/
private function createRememberMeSession(array $values)
{
if (! empty($values['remember_me'])) {
$credentials = $this->backend('rememberMe')
->create($this->userSession->getId(), Request::getIpAddress(), Request::getUserAgent());
$this->backend('rememberMe')->writeCookie($credentials['token'], $credentials['sequence'], $credentials['expiration']);
}
}
}

View File

@ -364,6 +364,71 @@ class User extends Base
->save(array('token' => ''));
}
/**
* Get the number of failed login for the user
*
* @access public
* @param string $username
* @return integer
*/
public function getFailedLogin($username)
{
return (int) $this->db->table(self::TABLE)->eq('username', $username)->findOneColumn('nb_failed_login');
}
/**
* Reset to 0 the counter of failed login
*
* @access public
* @param string $username
* @return boolean
*/
public function resetFailedLogin($username)
{
return $this->db->table(self::TABLE)->eq('username', $username)->update(array('nb_failed_login' => 0, 'lock_expiration_date' => 0));
}
/**
* Increment failed login counter
*
* @access public
* @param string $username
* @return boolean
*/
public function incrementFailedLogin($username)
{
return $this->db->execute('UPDATE '.self::TABLE.' SET nb_failed_login=nb_failed_login+1 WHERE username=?', array($username)) !== false;
}
/**
* Check if the account is locked
*
* @access public
* @param string $username
* @return boolean
*/
public function isLocked($username)
{
return $this->db->table(self::TABLE)
->eq('username', $username)
->neq('lock_expiration_date', 0)
->gte('lock_expiration_date', time())
->exists();
}
/**
* Lock the account for the specified duration
*
* @access public
* @param string $username Username
* @param integer $duration Duration in minutes
* @return boolean
*/
public function lock($username, $duration = 15)
{
return $this->db->table(self::TABLE)->eq('username', $username)->update(array('lock_expiration_date' => time() + $duration * 60));
}
/**
* Common validation rules
*

View File

@ -6,7 +6,13 @@ use PDO;
use Core\Security;
use Model\Link;
const VERSION = 81;
const VERSION = 82;
function version_82($pdo)
{
$pdo->exec("ALTER TABLE users ADD COLUMN nb_failed_login INT DEFAULT 0");
$pdo->exec("ALTER TABLE users ADD COLUMN lock_expiration_date INT DEFAULT 0");
}
function version_81($pdo)
{

View File

@ -6,7 +6,13 @@ use PDO;
use Core\Security;
use Model\Link;
const VERSION = 61;
const VERSION = 62;
function version_62($pdo)
{
$pdo->exec("ALTER TABLE users ADD COLUMN nb_failed_login INTEGER DEFAULT 0");
$pdo->exec("ALTER TABLE users ADD COLUMN lock_expiration_date INTEGER DEFAULT 0");
}
function version_61($pdo)
{

View File

@ -6,7 +6,13 @@ use Core\Security;
use PDO;
use Model\Link;
const VERSION = 77;
const VERSION = 78;
function version_78($pdo)
{
$pdo->exec("ALTER TABLE users ADD COLUMN nb_failed_login INTEGER DEFAULT 0");
$pdo->exec("ALTER TABLE users ADD COLUMN lock_expiration_date INTEGER DEFAULT 0");
}
function version_77($pdo)
{

View File

@ -10,11 +10,17 @@
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Username'), 'username') ?>
<?= $this->form->text('username', $values, $errors, array('autofocus', 'required')) ?><br/>
<?= $this->form->text('username', $values, $errors, array('autofocus', 'required')) ?>
<?= $this->form->label(t('Password'), 'password') ?>
<?= $this->form->password('password', $values, $errors, array('required')) ?>
<?php if ($captcha): ?>
<?= $this->form->label(t('Enter the text below'), 'captcha') ?>
<img src="<?= $this->url->href('auth', 'captcha') ?>"/>
<?= $this->form->text('captcha', $values, $errors, array('required')) ?>
<?php endif ?>
<?= $this->form->checkbox('remember_me', t('Remember Me'), 1, true) ?><br/>
<div class="form-actions">

View File

@ -88,3 +88,8 @@ defined('ENABLE_URL_REWRITE') or define('ENABLE_URL_REWRITE', isset($_SERVER['HT
// Hide login form
defined('HIDE_LOGIN_FORM') or define('HIDE_LOGIN_FORM', false);
// Bruteforce protection
defined('BRUTEFORCE_CAPTCHA') or define('BRUTEFORCE_CAPTCHA', 3);
defined('BRUTEFORCE_LOCKDOWN') or define('BRUTEFORCE_LOCKDOWN', 6);
defined('BRUTEFORCE_LOCKDOWN_DURATION') or define('BRUTEFORCE_LOCKDOWN_DURATION', 15);

View File

@ -4,7 +4,7 @@
"ext-mbstring" : "*",
"ext-gd" : "*",
"christian-riesen/otp" : "1.4",
"eluceo/ical": "*",
"eluceo/ical": "0.7.0",
"erusev/parsedown" : "1.5.3",
"fabiang/xmpp" : "0.6.1",
"fguillot/json-rpc" : "dev-master",
@ -15,7 +15,8 @@
"pimple/pimple" : "~3.0",
"swiftmailer/swiftmailer" : "@stable",
"symfony/console" : "@stable",
"symfony/event-dispatcher" : "~2.6"
"symfony/event-dispatcher" : "~2.6",
"gregwar/captcha": "1.*"
},
"autoload" : {
"psr-0" : {

74
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"hash": "1c0cc116db3d03c38df0f0efa59e9df7",
"hash": "d88040d41c5a7cfee8e6fcd9dbc4a591",
"packages": [
{
"name": "christian-riesen/base32",
@ -404,6 +404,54 @@
"homepage": "https://github.com/fguillot/simpleLogger",
"time": "2015-05-30 19:25:09"
},
{
"name": "gregwar/captcha",
"version": "v1.1",
"source": {
"type": "git",
"url": "https://github.com/Gregwar/Captcha.git",
"reference": "9caea9eb001a809ab8734b201ae07ebe4179238d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Gregwar/Captcha/zipball/9caea9eb001a809ab8734b201ae07ebe4179238d",
"reference": "9caea9eb001a809ab8734b201ae07ebe4179238d",
"shasum": ""
},
"require": {
"ext-gd": "*",
"php": ">=5.3.0"
},
"type": "captcha",
"autoload": {
"psr-4": {
"Gregwar\\Captcha\\": "/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Grégoire Passault",
"email": "g.passault@gmail.com",
"homepage": "http://www.gregwar.com/"
},
{
"name": "Jeremy Livingston",
"email": "jeremy.j.livingston@gmail.com"
}
],
"description": "Captcha generator",
"homepage": "https://github.com/Gregwar/Captcha",
"keywords": [
"bot",
"captcha",
"spam"
],
"time": "2015-05-13 06:34:33"
},
{
"name": "nickcernis/html-to-markdown",
"version": "2.2.1",
@ -452,16 +500,16 @@
},
{
"name": "pimple/pimple",
"version": "v3.0.0",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/silexphp/Pimple.git",
"reference": "876bf0899d01feacd2a2e83f04641e51350099ef"
"reference": "3313af5935dbc560fab845b76a1ca351b47855af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/silexphp/Pimple/zipball/876bf0899d01feacd2a2e83f04641e51350099ef",
"reference": "876bf0899d01feacd2a2e83f04641e51350099ef",
"url": "https://api.github.com/repos/silexphp/Pimple/zipball/3313af5935dbc560fab845b76a1ca351b47855af",
"reference": "3313af5935dbc560fab845b76a1ca351b47855af",
"shasum": ""
},
"require": {
@ -494,7 +542,7 @@
"container",
"dependency injection"
],
"time": "2014-07-24 09:48:15"
"time": "2015-07-30 09:57:46"
},
{
"name": "psr/log",
@ -589,16 +637,16 @@
},
{
"name": "symfony/console",
"version": "v2.7.2",
"version": "v2.7.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/Console.git",
"reference": "8cf484449130cabfd98dcb4694ca9945802a21ed"
"reference": "d6cf02fe73634c96677e428f840704bfbcaec29e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/Console/zipball/8cf484449130cabfd98dcb4694ca9945802a21ed",
"reference": "8cf484449130cabfd98dcb4694ca9945802a21ed",
"url": "https://api.github.com/repos/symfony/Console/zipball/d6cf02fe73634c96677e428f840704bfbcaec29e",
"reference": "d6cf02fe73634c96677e428f840704bfbcaec29e",
"shasum": ""
},
"require": {
@ -642,11 +690,11 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
"time": "2015-07-09 16:07:40"
"time": "2015-07-28 15:18:12"
},
{
"name": "symfony/event-dispatcher",
"version": "v2.7.2",
"version": "v2.7.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/EventDispatcher.git",
@ -706,7 +754,7 @@
"packages-dev": [
{
"name": "symfony/stopwatch",
"version": "v2.7.2",
"version": "v2.7.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/Stopwatch.git",

View File

@ -159,3 +159,12 @@ define('ENABLE_URL_REWRITE', false);
// Hide login form, useful if all your users use Google/Github/ReverseProxy authentication
define('HIDE_LOGIN_FORM', false);
// Enable captcha after 3 authentication failure
define('BRUTEFORCE_CAPTCHA', 3);
// Lock the account after 6 authentication failure
define('BRUTEFORCE_LOCKDOWN', 6);
// Lock account duration in minute
define('BRUTEFORCE_LOCKDOWN_DURATION', 15);

View File

@ -27,6 +27,7 @@ Security
- Always use HTTPS with a valid certificate
- If you make a mobile application, it's your job to store securely the user credentials on the device
- After 3 authentication failure on the user api, the end-user have to unlock his account by using the login form
- Two factor authentication is not yet available through the API
Protocol

View File

@ -0,0 +1,26 @@
Bruteforce Protection
=====================
The brute force protection of Kanboard works at the user account level:
- After 3 authentication failure for the same username, the login form show a captcha image to prevent automated bot tentatives.
- After 6 authentication failure, the user account is locked down for a period of 15 minutes.
This feature works only for authentication methods that use the login form.
However, **after 3 authentication failure through the user API**, the account have to be unlocked by using the login form.
Kanboard doesn't block any IP addresses since bots can use several anonymous proxies. However, you can use external tools like [fail2ban](http://www.fail2ban.org) to avoid massive scans.
Default settings can be changed with these configuration variables:
```php
// Enable captcha after 3 authentication failure
define('BRUTEFORCE_CAPTCHA', 3);
// Lock the account after 6 authentication failure
define('BRUTEFORCE_LOCKDOWN', 6);
// Lock account duration in minute
define('BRUTEFORCE_LOCKDOWN_DURATION', 15);
```

View File

@ -196,6 +196,20 @@ define('ENABLE_HSTS', true);
define('ENABLE_XFRAME', true);
```
Bruteforce protection
---------------------
```php
// Enable captcha after 3 authentication failure
define('BRUTEFORCE_CAPTCHA', 3);
// Lock the account after 6 authentication failure
define('BRUTEFORCE_LOCKDOWN', 6);
// Lock account duration in minute
define('BRUTEFORCE_LOCKDOWN_DURATION', 15);
```
Various settings
----------------

View File

@ -81,6 +81,7 @@ Using Kanboard
- [Advanced Search Syntax](search.markdown)
- [Command line interface](cli.markdown)
- [Syntax guide](syntax-guide.markdown)
- [Bruteforce protection](bruteforce-protection.markdown)
- [Frequently asked questions](faq.markdown)
Technical details

View File

@ -0,0 +1,39 @@
<?php
require_once __DIR__.'/Base.php';
use Model\User;
use Model\Authentication;
class AuthenticationTest extends Base
{
public function testHasCaptcha()
{
$u = new User($this->container);
$a = new Authentication($this->container);
$this->assertFalse($a->hasCaptcha('not_found'));
$this->assertFalse($a->hasCaptcha('admin'));
$this->assertTrue($u->incrementFailedLogin('admin'));
$this->assertTrue($u->incrementFailedLogin('admin'));
$this->assertTrue($u->incrementFailedLogin('admin'));
$this->assertFalse($a->hasCaptcha('not_found'));
$this->assertTrue($a->hasCaptcha('admin'));
}
public function testHandleFailedLogin()
{
$u = new User($this->container);
$a = new Authentication($this->container);
$this->assertFalse($u->isLocked('admin'));
for ($i = 0; $i <= 6; $i++) {
$a->handleFailedLogin('admin');
}
$this->assertTrue($u->isLocked('admin'));
}
}

View File

@ -12,6 +12,31 @@ use Model\Project;
class UserTest extends Base
{
public function testFailedLogin()
{
$u = new User($this->container);
$this->assertEquals(0, $u->getFailedLogin('admin'));
$this->assertEquals(0, $u->getFailedLogin('not_found'));
$this->assertTrue($u->incrementFailedLogin('admin'));
$this->assertTrue($u->incrementFailedLogin('admin'));
$this->assertEquals(2, $u->getFailedLogin('admin'));
$this->assertTrue($u->resetFailedLogin('admin'));
$this->assertEquals(0, $u->getFailedLogin('admin'));
}
public function testLocking()
{
$u = new User($this->container);
$this->assertFalse($u->isLocked('admin'));
$this->assertFalse($u->isLocked('not_found'));
$this->assertTrue($u->lock('admin', 1));
$this->assertTrue($u->isLocked('admin'));
}
public function testGetByEmail()
{
$u = new User($this->container);