Securing PHP login with Fail2Ban

Brute force attacks are a common issue for webapps. Combining Fail2Ban in Centos(Linux) and PHP logging can provide a powerful tool for temporarily or even permanently banning users from even reaching your server. This is an extreme measure but it is clean, simple and very effective.

The article below requires the following perquisites:

  • Fail2Ban installed (for step by step installation please visit the Fail2Ban official documentation)
  • PHP 4+ installed (I am using Php 8.0 but lower versions are acceptable too)
  • You already have a DB Model for Users

In this article:

  1. Create custom logging function
  2. Implement logging in your login check function
  3. Configure Fail2Ban to listen and act

The most common place to add this protection is for your login page, for access to any elevated security part of your website. The logic is that you log every failed attempt in a specific log file which is read by Fail2Ban and, upon meeting the conditions for receiving a temporary/permanent ban, fail2ban will adjust the firewall rules automatically.

This article covers just the essential steps for logging and banning. I have a different article on how to build the login functionality at Login Redirect with Codeigniter 4 so make sure you read that first.

Step 1: Start logging failed attempts in a standardized format

In our controller function which checks the user/pass combo we need to create a new function:

app/Controllers/Login.php

private function log_failed_login($username,$ip){
        // write login failed in logfile for fail2ban
        $dat = date('M j H:i:s Y');
        $error_message = "Authentication failed for: $username";
        error_log("[$dat] [WARNING] [client: $ip] $error_message\n", 3, WRITEPATH."/logs/php_login_failed.log");
    }

I am using the CodeIgniter 4 framework so the constant WRITEPATH in our case is ‘[root project directory]/writable/‘. If you want to specify a certain path make sure you specify the full path (i.e. /var/www/[project_folder]/writable/) and that the path is writable by the http server user (apache / ngnix).

Step 2: Use the logging function in your login check logic

There are two possible implementations for logging bad username/password combinations:

  • Log all unsuccessful attempts
  • Log only unsuccessful attempts for known users

My preference is to capture all attempts since this prevents the attacker from getting to guess a correct username. Therefore, the checkLogin() function should be modified as such:

app/Controllers/Login.php

    public function checkLogin(){
        $username = $this->request->getVar('username');
        $password = $this->request->getVar('password');
        $user_class = new Users();
        $user = $user_class->findWhere(['username'=>$username]);
         
       if($user && password_verify($password,$user['password'])){
               $this->session->set('user',$user);
               return redirect()->to(base_url());
       } else {
               $this->session->setFlashdata('error','Keep trying..');
               $this->log_failed_login($username,get_client_ip());
               return redirect()->to(base_url().'/login');
       }
    }

You will notice the IP of the client is retreived via the get_client_ip() method or, if Login.php extends the BaseController in CodeIgniter4, you can access it directly by $this->request->getIPAddress()

For your convenience, here is the code you should have in your common functions file or, if you dont plan to use it elsewhere, right in the Login.php controller:

function get_client_ip() {
    $ipaddress = '';
    if (isset($_SERVER['HTTP_CLIENT_IP']))
        $ipaddress = $_SERVER['HTTP_CLIENT_IP'];
    else if(isset($_SERVER['HTTP_X_FORWARDED_FOR']))
        $ipaddress = $_SERVER['HTTP_X_FORWARDED_FOR'];
    else if(isset($_SERVER['HTTP_X_FORWARDED']))
        $ipaddress = $_SERVER['HTTP_X_FORWARDED'];
    else if(isset($_SERVER['HTTP_FORWARDED_FOR']))
        $ipaddress = $_SERVER['HTTP_FORWARDED_FOR'];
    else if(isset($_SERVER['HTTP_FORWARDED']))
        $ipaddress = $_SERVER['HTTP_FORWARDED'];
    else if(isset($_SERVER['REMOTE_ADDR']))
        $ipaddress = $_SERVER['REMOTE_ADDR'];
    else
        $ipaddress = 'UNKNOWN';

    if (isIPv4($ipaddress)){
        return $ipaddress;
    }else{
        return 'UNKNOWN';
    }
}

function isIPv4($ip) {
    if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
        return true;
    } else {
        return false;
    }
}

Any failed attempts on your login form will produce the following content:

[full][path][to][logfile]/php_login_failed.log

[Jun 22 22:41:19 2022] [WARNING] [client: 192.168.0.2] Authentication failed for: admin
[Jun 22 22:42:50 2022] [WARNING] [client: 192.168.0.2] Authentication failed for: root
[Jun 22 22:43:13 2022] [WARNING] [client: 192.168.0.2] Authentication failed for: user
[Jun 25 11:36:34 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: test
[Jun 25 11:36:51 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: johndoe
[Jun 26 10:29:49 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: janedoe
[Jun 26 10:30:17 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: administrator
[Jun 26 10:30:34 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: root
[Jun 28 13:19:11 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: guest
[Jun 28 13:19:36 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: fake_user
[Jun 28 13:23:10 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: mary
[Jun 28 13:23:38 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: joe
[Jun 28 13:32:53 2022] [WARNING] [client: 192.168.0.10] Authentication failed for: jane
[Jun 29 13:08:52 2022] [WARNING] [client: 192.168.0.2] Authentication failed for: root

Step 3: Configure Fail2ban to check your failed log and take action

Now we need to let Fail2Ban know where to read the log from, how to interpret it and then what actions to take.

First we need to create a new filter configuration /etc/fail2ban/filter.d/php-login.conf

[Definition]
failregex = \[WARNING\] \[client: <HOST>\] Authentication failed for: .*$
ignoreregex =

Then, we need to create a new Jail definition in /etc/fail2ban/jail.local

[php-login]
enabled  = true
bantime  = 1d
findtime  = 60m
maxretry = 5
port     = http,https
filter   = php-login
logpath  = [full][path][to][logfile]/php_login_failed.log

The above is pretty much very aggressive. It tells Fail2Ban to ban for 1 day (bantime), any IP that has had at least 5 attempts (maxretry) in the last 60 minutes (findtime). The resulting ban action will happen only for HTTP(s) traffic. If you want to be even more aggressive you can remove the ‘port‘ line and this will ban the found IP for any type of traffic.

The only thing to do is to restart fail2ban (CentOS):

systemctl restart fail2ban

Check that fail2ban is running with the php-login filter:

fail2ban-client status

should produce the following result:

Status
|- Number of jail:      2
`- Jail list:  php-login, sshd

You’re all set now! And pretty much protected against brute force, if you ask me 🙂


Posted

in

, , ,

by