I had to write this (limited) solution to protect a login form. This class should help adding a captcha after some failed logins attempts, it will lock IPs for some time or even ban them completely. It also helps checking if the IP is considered safe, giving you the possibility to protect parts of your application if it is not.
A few preliminary remarks. At least every form available to guest users should be somehow protected:
-
Signup forms should always use a strong captcha.
-
Password recovery also.
-
Login forms could use a captcha after some failed attempts and should lock IPs after too many failed attempts in a certain amount of time.
I created a naive class that helps with logins and determines if an IP is to be considered safe or not.
WARNING: THIS CODE COULD BE UNSAFE AND EVEN CONTAIN BUGS. JUST USE AT YOUR OWN RISK AND DO NOT RELY ENTIRELY ON IT. Use it as a starting point for your own solution. And most of all: please, make critics and suggestions on how to improve it!
[size="4"]HOW TO USE THE DEFENDER CLASS[/size]
Let’s start. Using the Defender class in the login controller is extremely simple.
[size="3"]1. Allow the login action to run only if the IP is considered safe (not locked).[/size]
public function actionLogin()
{
$defender = new Defender();
if (!$defender->isSafeIp())
{
exit();
// that's all - once an IP is locked I suggest not to send any further information.
// simply exit() the application as soon as possible without any error messages.
}
// go on with login controller if ip is safe
}
[size="3"]2. Get the number of failed logins and add a Captcha test (if you want).[/size]
// Add captcha after 3 failed logins (4th try)
if ($defender->failedLogins >= $defender->maxLoginsBeforeCaptcha)
$model->captcha = true;
// or if you want to use scenarios, you could do:
// $model->scenario = 'captchaRequired';
[size="3"]3. Record a failed login for the current ip if validation fails.[/size]
if (!$model->validate()){
$defender->recordFailedLogin();
// your code here
}
[size="3"]4. OPTIONAL: You can slow down logins, after 3 or more failed attempts by using sleep() in a careful way.[/size]
if (!$model->validate()){
if ($defender->failedLogins > 3) sleep($defender->failedLogins);
// your code here
// ATTENTION: do not let the attacker make use of the sleep() function too much. just use it for a few requests and then lock the ip. if you let the attacker launch thousand of sleep() commands it will kill your site.
}
[size="3"]5. Remove failed login records for the current ip after validation (successful login).[/size]
if ($model->validate()){
$defender->removeFailedLogins();
// your code here
}
[size="4"]SETUP YOUR DB[/size]
Add two identical tables to your database: we will call them ‘failed_logins’ and ‘locked_ips’ (this could be done with a single table, but it is cleaner and faster with two):
CREATE TABLE `failed_logins` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ip` varchar(45) NOT NULL,
`time` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
CREATE TABLE `locked_ips` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ip` varchar(45) NOT NULL,
`time` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
[size="4"]THE CLASS[/size]
The new class will extend CComponents. It has a few comments on it (sorry for my english).
You should disable all the logs too! -> Yii::log().
<?php
class Defender extends CComponent
{
/**
* User Host IP Address.
* @var string
*/
public $ip;
/**
* Allowed failed logins during the 'failedLoginsTestDuration' period.
* @var int Failed login attempts.
*/
public $failedLoginsAllowed = 20; // 20 failed logins in $failedLoginsTestDuration seconds
/**
* Max number of failed logins allowed before showing a Captcha test.
*/
public $maxLoginsBeforeCaptcha = 3;
/**
* Test period for login errors count, from now backwards.
* @var int Time in seconds.
*/
public $failedLoginsTestDuration = 300; // 5 minutes
/**
* Duration of the lock.
* @var int Time in seconds.
*/
public $lockDuration = 1800; // 30 minutes
/**
* Number of locks tolerated before definitive ban of the ip.
* @var int Number of locks: 0 to disable ban.
*/
public $locksBeforeBan = 2;
/**
* Db tables with records about failed logins, locked ips and banned ips.
* @var string The db table name.
*/
public $failedLoginsTable = "{{failed_logins}}";
public $locksTable = "{{locked_ips}}";
/**
* The total number of failed logins in the test period. READ ONLY.
* @var int
*/
public $failedLogins;
/**
* The total number of times an Ip has been locked. READ ONLY.
* @var int
*/
public $timesLocked;
/**
* The result of a query on the ip_locks table. List of lock records for the current ip.
* @var array
*/
private $locks;
/**
* Whether the ip is locked or banned. READ ONLY.
* @var int
*/
public $isLocked;
public $isBanned;
/**
* Create an instance of the defender based on the ip provided.
* @param string $ip User Host IP Address.
*/
public function __construct()
{
$this->ip = Yii::app()->request()->getUserHostAddress();
// Get lock record for the current ip.
$this->locks = $this->getLocks();
// Stores data about the current ip.
$this->timesLocked = $this->locks->rowCount;
$this->isLocked = (boolean) $this->isLocked();
$this->isBanned = (boolean) $this->isBanned();
// Store the number of failed logins for the currenti ip.
if (!$this->isLocked && !$this->isBanned)
{
$this->failedLogins = $this->countLoginErrors();
}
else
{
// If locked or banned, the number of failed login is always 1000 more than allowed.
$this->failedLogins = $this->failedLoginsAllowed + 1000;
}
}
/**
* Check the session to get login failures: returns false if the user has
* too many failures in a small time span.
* @param array $session User's session.
* @param int $ip User's ip.
* @return boolean Wheter the user has the right to continue logging in.
*/
public function isSafeIp()
{
if ($this->isLocked || $this->isBanned)
{
return false;
}
return true;
}
/**
* Lock ip. This ip will be locked for the time defined in $this->lockDuration
* @param string $ip
*/
public function lockIp()
{
if (!$this->isLocked)
{
Yii::log('Now locked: ' . $this->ip, 'warning');
return Yii::app()->db->createCommand()
->insert($this->locksTable, array(
'ip' => $this->ip,
'time' => time(),
)
);
}
Yii::log('Already locked: ' . $this->ip, 'warning');
}
/**
* Check wether the ip is currently locked.
* @return boolean
*/
public function isLocked()
{
foreach ($this->locks as $lock)
{
if ($lock['time'] > time() - $this->lockDuration)
{
Yii::log('Ip locked? YES', 'warning');
Yii::log('Time locked? ' . $lock['time'], 'warning');
return true;
}
}
Yii::log('Ip locked? NO', 'warning');
return false;
}
/**
* Check wether the ip is banned.
* @return int
*/
public function isBanned()
{
if ($this->timesLocked >= $this->locksBeforeBan)
{
Yii::log('Ip banned? YES', 'warning');
return true;
}
else
{
Yii::log('Ip banned? NO', 'warning');
return false;
}
}
/**
* Get lock records for the current ip.
* @return array Array with ip
*/
public function getLocks()
{
return Yii::app()->db->createCommand()
->select('time')
->from($this->locksTable)
->where('ip=:ip', array(':ip' => $this->ip))
->query();
}
/**
* Get the number of failed logins for the current ip.
* @param type $ip The ip of the current user.
* @return int
*/
public function countLoginErrors()
{
$errors = Yii::app()->db->createCommand()
->select('count(*) as num')
->from($this->failedLoginsTable)
->where('ip=:ip and time>:time', array(
':ip' => $this->ip,
':time' => time() - $this->failedLoginsTestDuration
)
)
->queryScalar();
return $errors;
}
/**
* Save ip and time of a failed login attempt in the db and delete old records.
* @param array $session User's session.
* @param int $ip User's ip.
*/
public function recordFailedLogin()
{
// @TODO Move the deletion of old record to a CRON JOB when deploying the application.
// $deleted = Yii::app()->db->createCommand()->delete($this->failedLoginsTable, 'time<:time', array(
// ':time' => (time() - $this->failedLoginsTestDuration)
// )
// );
// Yii::log('Rows deleted: ' . $deleted, 'warning');
$inserted = Yii::app()->db->createCommand()
->insert($this->failedLoginsTable, array(
'ip' => $this->ip,
'time' => time(),
)
);
if ($this->failedLogins + 1 > $this->failedLoginsAllowed)
$this->lockIp();
Yii::log('Rows inserted: ' . $inserted, 'warning');
}
/**
* Remove every failed login record about the current ip
* @return integer number of rows deleted
*/
public function removeFailedLogins()
{
return Yii::app()->db->createCommand()->delete($this->failedLoginsTable, 'ip=:ip', array(
':ip' => $this->ip
)
);
}
}
Hope it could be useful to someone.