1
0
mirror of https://github.com/processwire/processwire.git synced 2025-08-12 01:34:31 +02:00

Refactoring of SessionLoginThrottle. Prevents it from being too aggressive when TFA is in use, improves clarity of message to user, and adds the ability to log failures.

This commit is contained in:
Ryan Cramer
2018-09-14 12:03:16 -04:00
parent 64680df68f
commit 42b46152eb
4 changed files with 122 additions and 28 deletions

View File

@@ -23,6 +23,7 @@
* @method void init() Initialize session (called automatically by constructor) #pw-hooker * @method void init() Initialize session (called automatically by constructor) #pw-hooker
* @method bool authenticate(User $user, $pass) #pw-hooker * @method bool authenticate(User $user, $pass) #pw-hooker
* @method bool isValidSession($userID) #pw-hooker * @method bool isValidSession($userID) #pw-hooker
* @method bool allowLoginAttempt($name) #pw-hooker
* @method bool allowLogin($name, User $user = null) #pw-hooker * @method bool allowLogin($name, User $user = null) #pw-hooker
* @method void loginSuccess(User $user) #pw-hooker * @method void loginSuccess(User $user) #pw-hooker
* @method void loginFailure($name, $reason) #pw-hooker * @method void loginFailure($name, $reason) #pw-hooker
@@ -793,11 +794,16 @@ class Session extends Wire implements \IteratorAggregate {
if(!strlen($name)) return null; if(!strlen($name)) return null;
if(is_null($user)) { $allowAttempt = $this->allowLoginAttempt($name);
if($allowAttempt && is_null($user)) {
$user = $users->get('name=' . $sanitizer->selectorValue($name)); $user = $users->get('name=' . $sanitizer->selectorValue($name));
} }
if(!$allowAttempt) {
$failReason = 'Blocked login attempt';
if(!$user || !$user->id) { } else if(!$user || !$user->id) {
$failReason = 'Unknown user'; $failReason = 'Unknown user';
} else if($user->id == $guestUserID) { } else if($user->id == $guestUserID) {
@@ -927,6 +933,21 @@ class Session extends Wire implements \IteratorAggregate {
return $allow; return $allow;
} }
/**
* Allow login attempt for given name at all?
*
* This method does nothing and is purely for hooks to modify return value.
*
* #pw-hooker
*
* @param string $name
* @return bool
*
*/
public function ___allowLoginAttempt($name) {
return strlen($name) > 0;
}
/** /**
* Return true or false whether the user authenticated with the supplied password * Return true or false whether the user authenticated with the supplied password
* *

View File

@@ -1454,7 +1454,8 @@ class FieldtypePage extends FieldtypeMulti implements Module, ConfigurableModule
// if there was nothing found, finish early // if there was nothing found, finish early
if(!$total) { if(!$total) {
// no references found and PageArray requested, return blank PageArray // no references found
if($getCount) return $total;
return $byField ? array() : $pages->newPageArray(); return $byField ? array() : $pages->newPageArray();
} }

View File

@@ -98,7 +98,8 @@ class ProcessRecentPages extends Process {
$this->addHookProperty('Page::recentTimeStr', $this, 'hookPageRecentTimeStr'); $this->addHookProperty('Page::recentTimeStr', $this, 'hookPageRecentTimeStr');
parent::init(); parent::init();
$this->labels['nothing'] = $this->_('No pages match (yet)'); $this->labels['nothing'] = $this->_('No pages match (yet)');
$this->oldestDate = (int) @filemtime($this->wire('config')->paths->assets . 'installed.php'); $this->oldestDate = (int) $this->wire('config')->installed;
if(!$this->oldestDate) $this->oldestDate = @filemtime($this->wire('config')->paths->assets . 'installed.php');
} }
/** /**

View File

@@ -5,9 +5,14 @@
* *
* Throttles the frequency of logins for a given account, helps to reduce dictionary attacks. * Throttles the frequency of logins for a given account, helps to reduce dictionary attacks.
* *
* ProcessWire 3.x, Copyright 2016 by Ryan Cramer * ProcessWire 3.x, Copyright 2018 by Ryan Cramer
* https://processwire.com * https://processwire.com
* *
*
* @property int|bool $checkIP
* @property int $seconds
* @property int $maxSeconds
* @property int|bool $logFails
* *
*/ */
@@ -16,22 +21,41 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
public static function getModuleInfo() { public static function getModuleInfo() {
return array( return array(
'title' => 'Session Login Throttle', 'title' => 'Session Login Throttle',
'version' => 102, 'version' => 103,
'summary' => 'summary' => 'Throttles login attempts to help prevent dictionary attacks.',
'Throttles the frequency of logins for a given account, helps to reduce dictionary attacks ' .
'by introducing an exponential delay between logins.',
'permanent' => false, 'permanent' => false,
'singular' => true, 'singular' => true,
'autoload' => function() { return count($_POST) > 0; } 'autoload' => function() { return count($_POST) > 0; }
); );
} }
/**
* Default module settings
*
* @var array
*
*/
protected static $defaultSettings = array( protected static $defaultSettings = array(
'checkIP' => 0, 'checkIP' => 0,
'seconds' => 5, 'seconds' => 5,
'maxSeconds' => 60 'maxSeconds' => 60,
); 'logFails' => 0,
);
/**
* Cached results of allowLogin() in case there are multiple calls
*
* This ensures that only one attempt can be recorded per request.
*
* @var array of name (string) => result (bool)
*
*/
protected $allowLoginResults = array();
/**
* Construct
*
*/
public function __construct() { public function __construct() {
foreach(self::$defaultSettings as $key => $value) { foreach(self::$defaultSettings as $key => $value) {
$this->set($key, $value); $this->set($key, $value);
@@ -44,20 +68,22 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
*/ */
public function init() { public function init() {
if($this->wire('config')->demo) return; if($this->wire('config')->demo) return;
$this->session->addHookAfter('allowLogin', $this, 'sessionAllowLogin'); $this->session->addHookAfter('allowLoginAttempt', $this, 'hookSessionAllowLoginAttempt');
} }
/** /**
* Hooks into Session::authenticate to make it respond 'false' if the user has already failed a login. * Hooks into Session::authenticate to make it respond 'false' if the user has already failed a login.
* *
* Further, it imposes an increasing delay for every failed attempt * Further, it imposes an increasing delay for every failed attempt
*
* @param HookEvent $event
* *
*/ */
public function sessionAllowLogin($event) { public function hookSessionAllowLoginAttempt(HookEvent $event) {
// check if some other module has already disallowed login, in which case we won't do anything // check if some other module has already disallowed login, in which case we won't do anything
$allowed = $event->return; $allowed = $event->return;
if(!$allowed) return false; if(!$allowed) return;
$name = $event->arguments[0]; $name = $event->arguments[0];
@@ -73,7 +99,17 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
$event->return = $allowed; $event->return = $allowed;
} }
/**
* Allow given user name to login?
*
* @param string $name User name, may also be IP address
* @return bool
* @throws WireException
*
*/
protected function allowLogin($name) { protected function allowLogin($name) {
if(isset($this->allowLoginResults[$name])) return $this->allowLoginResults[$name];
$time = time(); $time = time();
$database = $this->wire('database'); $database = $this->wire('database');
@@ -83,7 +119,13 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
$query->bindValue(":name", $name); $query->bindValue(":name", $name);
$query->execute(); $query->execute();
$numRows = $query->rowCount(); $numRows = $query->rowCount();
if($numRows) list($attempts, $lastAttempt) = $query->fetch(\PDO::FETCH_NUM); if($numRows) {
list($attempts, $lastAttempt) = $query->fetch(\PDO::FETCH_NUM);
} else {
$attempts = 0;
$lastAttempt = 0;
}
$query->closeCursor();
$allowed = false; $allowed = false;
if($numRows) { if($numRows) {
@@ -92,9 +134,21 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
if($requireSeconds > $this->maxSeconds) $requireSeconds = $this->maxSeconds; if($requireSeconds > $this->maxSeconds) $requireSeconds = $this->maxSeconds;
$elapsedSeconds = $time - $lastAttempt; $elapsedSeconds = $time - $lastAttempt;
if($elapsedSeconds < $requireSeconds) { if($elapsedSeconds < $requireSeconds) {
$error = sprintf($this->_("Please wait at least %d seconds before attempting another login."), $requireSeconds); if($this->logFails) {
if($this->wire('process') == 'ProcessLogin') parent::error($error); $this->wire('log')->save(
else throw new WireException($error); // ensures the error can't be missed in unknown API usage "login-throttle",
"Blocked login attempt for '$name' (attempts=$attempts, seconds=$requireSeconds)",
array('showUser' => false)
);
}
$error =
$this->_('Login not attempted due to overflow.') . ' ' .
sprintf($this->_("Please wait at least %d seconds before attempting another login."), $requireSeconds);
if($this->wire('process') == 'ProcessLogin') {
parent::error($error);
} else {
throw new WireException($error); // ensures the error can't be missed in unknown API usage
}
} else { } else {
$allowed = true; $allowed = true;
} }
@@ -105,23 +159,26 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
// if there have been more than $this->maxSeconds since the previous attempt, consider this as a first login attempt (@jlj) // if there have been more than $this->maxSeconds since the previous attempt, consider this as a first login attempt (@jlj)
if($time - $lastAttempt > $this->maxSeconds) $attempts = 1; if($time - $lastAttempt > $this->maxSeconds) $attempts = 1;
$query = $database->prepare('UPDATE session_login_throttle SET attempts=:attempts, last_attempt=:time WHERE name=:name'); $sql = 'UPDATE session_login_throttle SET attempts=:attempts, last_attempt=:time WHERE name=:name';
$query = $database->prepare($sql);
$query->bindValue(':attempts', $attempts); $query->bindValue(':attempts', $attempts);
$query->bindValue(':time', $time); $query->bindValue(':time', $time);
$query->bindValue(':name', $name); $query->bindValue(':name', $name);
$query->execute(); $query->execute();
} else { } else {
$allowed = true; $allowed = true;
$attempts = 1;
$query = $database->prepare('INSERT INTO session_login_throttle (name, attempts, last_attempt) VALUES(:name, :attempts, :last_attempt)');
$sql = 'INSERT INTO session_login_throttle (name, attempts, last_attempt) VALUES(:name, :attempts, :last_attempt)';
$query = $database->prepare($sql);
$query->bindValue(":name", $name); $query->bindValue(":name", $name);
$query->bindValue(":attempts", 1, \PDO::PARAM_INT); $query->bindValue(":attempts", $attempts, \PDO::PARAM_INT);
$query->bindValue(":last_attempt", $time, \PDO::PARAM_INT); $query->bindValue(":last_attempt", $time, \PDO::PARAM_INT);
$query->execute(); $query->execute();
} }
// delete saved login attempts that are no longer applicable // delete saved login attempts that are no longer applicable
$expired = $time - $this->maxSeconds; $expired = $time - $this->maxSeconds;
@@ -129,6 +186,8 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
$query = $database->prepare($sql); $query = $database->prepare($sql);
$query->bindValue(":expired", $expired, \PDO::PARAM_INT); $query->bindValue(":expired", $expired, \PDO::PARAM_INT);
$query->execute(); $query->execute();
$this->allowLoginResults[$name] = $allowed;
return $allowed; return $allowed;
} }
@@ -136,16 +195,21 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
/** /**
* Add custom config options (coming soon, just a placeholder for now) * Add custom config options (coming soon, just a placeholder for now)
*
* @param array $data
* @return InputfieldWrapper
* *
*/ */
public function getModuleConfigInputfields(array $data) { public function getModuleConfigInputfields(array $data) {
/** @var InputfieldWrapper $inputfields */
$inputfields = $this->wire(new InputfieldWrapper()); $inputfields = $this->wire(new InputfieldWrapper());
foreach(self::$defaultSettings as $key => $value) { foreach(self::$defaultSettings as $key => $value) {
if(!isset($data[$key])) $data[$key] = $value; if(!isset($data[$key])) $data[$key] = $value;
} }
/** @var InputfieldCheckbox $f */
$f = $this->wire('modules')->get('InputfieldCheckbox'); $f = $this->wire('modules')->get('InputfieldCheckbox');
$f->attr('name', 'checkIP'); $f->attr('name', 'checkIP');
$f->attr('value', 1); $f->attr('value', 1);
@@ -154,6 +218,7 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
$f->description = $this->_('By default, throttling will only be done by username. If you check this box, then throttling will also be done by IP address. We recommended enabling this option if your users are not coming from a shared IP address.'); $f->description = $this->_('By default, throttling will only be done by username. If you check this box, then throttling will also be done by IP address. We recommended enabling this option if your users are not coming from a shared IP address.');
$inputfields->add($f); $inputfields->add($f);
/** @var InputfieldInteger $f */
$f = $this->wire('modules')->get('InputfieldInteger'); $f = $this->wire('modules')->get('InputfieldInteger');
$f->attr('name', 'seconds'); $f->attr('name', 'seconds');
$f->attr('value', $data['seconds']); $f->attr('value', $data['seconds']);
@@ -167,10 +232,16 @@ class SessionLoginThrottle extends WireData implements Module, ConfigurableModul
$f->label = $this->_('Maximum number of seconds a user would ever have to wait before attempting another login'); $f->label = $this->_('Maximum number of seconds a user would ever have to wait before attempting another login');
$f->description = $this->_('Because the wait time is increased exponentially on each attempt, this places a maximum (cap) on the wait time. You should leave this set to a fairly high number.'); $f->description = $this->_('Because the wait time is increased exponentially on each attempt, this places a maximum (cap) on the wait time. You should leave this set to a fairly high number.');
$f->notes = $this->_('60=1 minute, 300=5 minutes, 600=10 minutes, 3600=1 hour, 86400=1 day'); $f->notes = $this->_('60=1 minute, 300=5 minutes, 600=10 minutes, 3600=1 hour, 86400=1 day');
$inputfields->add($f); $inputfields->add($f);
/** @var InputfieldCheckbox $f */
$f = $this->wire('modules')->get('InputfieldCheckbox');
$f->attr('name', 'logFails');
if($data['logFails']) $f->attr('checked', 'checked');
$f->label = $this->_('Save to log file when name/IP is blocked?');
$inputfields->add($f);
return $inputfields; return $inputfields;
} }
/** /**