Defender Class To Manage Failed Logins

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.

If your code allows to login through ajax, you can make a very simple DOS or brute force test with Firebug, to see the Defender working. For example, you could try to login 1000 times with email (or username) and password.




// If you are using csrf protection you should add variables for csrfToken and csrfTokenName

var csrfToken = "token here";

var csrfTokenName = "name here";


for (var i=0; i<1000; i++) {

		var data = {

		    LoginForm: {

		        email: "whatever@email.com",

		        password: "password",

		        rememberMe: 0

		    }

		};

		data[csrfTokenName] = csrfToken;


		$.ajax({

	    	url: Pi.basePath + "/user/login/",

		    method: "post",

		    data: data,

		    success: function(data) {

		        console.log(data);

		    }

		});

	}



You will see that it will slow down after 3 tries (if you added the sleep() option), and your IP will be locked after 20 times. All the other request will just get an empty response (with exit()).

I really like this script, works great and provides a basis for further customization. I’ve created a version of the script to restrict access to an admin login form. I have a table called “admin_ips” which I edit via remote mysql to have the current ip addresses of the admins (a bit of a pain, but worth the trade off).

Here’s my simple AdminDefender class which I have in my admin module components folder.


<?php




class AdminDefender extends CComponent

{


    /**

     * User Host IP Address.

     * @var string

     */

    public $ip;


    /**

     * The admin ip address table.

     * @var int

     */

    public $adminsTable = "admin_ips";


    /**

     * Whether the ip is admin. READ ONLY.

     * @var int

     */

    public $isAdmin;


    /**

     * Create an instance of the security manager based on the ip provided.

     * @param string $ip User Host IP Address.

     */

    public function __construct()

    {


        //$this->ip = request()->getUserHostAddress();

        $this->ip = Yii::app()->request->getUserHostAddress();


        // Stores data about the current ip.

        $this->isAdmin = (boolean) $this->isAdmin();


    }


    /**

     * Check to see if the user's ip is listed in the adminip table

     * @param array $session User's session.

     * @param int $ip User's ip.

     * @return boolean Whether the user has the right to continue logging in.

     */

    public function isAdminIp()

    {

        if ($this->isAdmin)

        {

            return false;

        }

        return true;

    }


    /**

     * Check whether the ip is admin.

     * @return boolean

     */

    public function isAdmin()

    {

        foreach ($this->getAdmins() as $admin)

        {

            if ($admin['ip'] == $this->ip)

            {

                return true;

            }

        }

        return false;

    }


    /**

     * Check admin ip table for match with current user ip

     * @return array Array with ip

     */

    public function getAdmins()

    {

        return Yii::app()->db->createCommand()

            ->select('ip')

            ->from($this->adminsTable)

            ->where('ip=:ip', array(':ip' => $this->ip))

            ->query();

    }


}



Then all that is needed is a little edit to the actionLogin() for the admin section:


        $defender = new AdminDefender();

        if (!$defender->isAdmin())

        {

            $this->redirect(Yii::app()->homeUrl);

        }

And now the admin login form is only visible to those whose ip addresses are in the admin ips table. I know the code is a bit rough around the edges in my little hack, but it works. :)

Hi Este,

In your Defender class this line is not correct:


$this->ip = Yii::app()->request()->getUserHostAddress();

Instead of that it need to be


$this->ip = Yii::app()->request->getUserHostAddress();