diff options
-rw-r--r-- | ChangeLog | 13 | ||||
-rw-r--r-- | app/Api/Auth.php | 2 | ||||
-rw-r--r-- | app/Controller/Auth.php | 18 | ||||
-rw-r--r-- | app/Model/Acl.php | 2 | ||||
-rw-r--r-- | app/Model/Authentication.php | 117 | ||||
-rw-r--r-- | app/Model/User.php | 65 | ||||
-rw-r--r-- | app/Schema/Mysql.php | 8 | ||||
-rw-r--r-- | app/Schema/Postgres.php | 8 | ||||
-rw-r--r-- | app/Schema/Sqlite.php | 8 | ||||
-rw-r--r-- | app/Template/auth/index.php | 8 | ||||
-rw-r--r-- | app/constants.php | 5 | ||||
-rw-r--r-- | composer.json | 5 | ||||
-rw-r--r-- | composer.lock | 74 | ||||
-rw-r--r-- | config.default.php | 9 | ||||
-rw-r--r-- | docs/api-json-rpc.markdown | 1 | ||||
-rw-r--r-- | docs/bruteforce-protection.markdown | 26 | ||||
-rw-r--r-- | docs/config.markdown | 14 | ||||
-rw-r--r-- | docs/index.markdown | 1 | ||||
-rw-r--r-- | tests/units/AuthenticationTest.php | 39 | ||||
-rw-r--r-- | tests/units/UserTest.php | 25 |
20 files changed, 405 insertions, 43 deletions
@@ -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 -------------- diff --git a/app/Api/Auth.php b/app/Api/Auth.php index 9d401746..18fe9ff9 100644 --- a/app/Api/Auth.php +++ b/app/Api/Auth.php @@ -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)); } diff --git a/app/Controller/Auth.php b/app/Controller/Auth.php index e8889b7f..bb1154e4 100644 --- a/app/Controller/Auth.php +++ b/app/Controller/Auth.php @@ -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(); + } } diff --git a/app/Model/Acl.php b/app/Model/Acl.php index 95056de6..a47886bb 100644 --- a/app/Model/Acl.php +++ b/app/Model/Acl.php @@ -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' => '*', diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php index 31969b57..f09312bd 100644 --- a/app/Model/Authentication.php +++ b/app/Model/Authentication.php @@ -5,6 +5,7 @@ namespace Model; use Core\Request; use SimpleValidator\Validator; use SimpleValidator\Validators; +use Gregwar\Captcha\CaptchaBuilder; /** * Authentication model @@ -75,18 +76,52 @@ 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 * * @access public @@ -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']); + } + } } diff --git a/app/Model/User.php b/app/Model/User.php index b6804abc..8daef3f2 100644 --- a/app/Model/User.php +++ b/app/Model/User.php @@ -365,6 +365,71 @@ class User extends Base } /** + * 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 * * @access private diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php index 47fb806e..31ffaa32 100644 --- a/app/Schema/Mysql.php +++ b/app/Schema/Mysql.php @@ -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) { diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php index 6b85ff57..876df981 100644 --- a/app/Schema/Postgres.php +++ b/app/Schema/Postgres.php @@ -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) { diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php index 9e0575cf..4b8f5ac6 100644 --- a/app/Schema/Sqlite.php +++ b/app/Schema/Sqlite.php @@ -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) { diff --git a/app/Template/auth/index.php b/app/Template/auth/index.php index ca303df9..efe95185 100644 --- a/app/Template/auth/index.php +++ b/app/Template/auth/index.php @@ -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"> diff --git a/app/constants.php b/app/constants.php index 83fba468..e232aba6 100644 --- a/app/constants.php +++ b/app/constants.php @@ -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); diff --git a/composer.json b/composer.json index a6f6135e..375d2ee8 100644 --- a/composer.json +++ b/composer.json @@ -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" : { diff --git a/composer.lock b/composer.lock index 612167c1..9009a97d 100644 --- a/composer.lock +++ b/composer.lock @@ -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", @@ -405,6 +405,54 @@ "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", "source": { @@ -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", diff --git a/config.default.php b/config.default.php index c392dcad..76ec38cc 100644 --- a/config.default.php +++ b/config.default.php @@ -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); diff --git a/docs/api-json-rpc.markdown b/docs/api-json-rpc.markdown index 9c572304..7c211081 100644 --- a/docs/api-json-rpc.markdown +++ b/docs/api-json-rpc.markdown @@ -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 diff --git a/docs/bruteforce-protection.markdown b/docs/bruteforce-protection.markdown new file mode 100644 index 00000000..633cfe87 --- /dev/null +++ b/docs/bruteforce-protection.markdown @@ -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); +``` diff --git a/docs/config.markdown b/docs/config.markdown index 45ba7a91..44f72b32 100644 --- a/docs/config.markdown +++ b/docs/config.markdown @@ -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 ---------------- diff --git a/docs/index.markdown b/docs/index.markdown index 5ef523f9..181e1d03 100644 --- a/docs/index.markdown +++ b/docs/index.markdown @@ -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 diff --git a/tests/units/AuthenticationTest.php b/tests/units/AuthenticationTest.php new file mode 100644 index 00000000..75b55ece --- /dev/null +++ b/tests/units/AuthenticationTest.php @@ -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')); + } +} diff --git a/tests/units/UserTest.php b/tests/units/UserTest.php index fcdf3934..6c68dfd2 100644 --- a/tests/units/UserTest.php +++ b/tests/units/UserTest.php @@ -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); |