diff options
Diffstat (limited to 'app')
-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 |
10 files changed, 213 insertions, 28 deletions
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); |