Implementing a login fail2ban like system for Symfony with Redis

Published on 2018-11-16

In this post we will see how to implement a fail2ban like system for Symfony. It will log login failure attempts for a given IP and will prevent further tries once a critical threshold is reached. When happening, a customized error page will be displayed to the user. Let's go! πŸ˜€

Configuration and prerequisites

I will assume you are already familiar with Symfony and on how to install a bundle with composer. This post was written using the following components:

The SncRedisBundle must be configured with a default client, it will provide the @snc_redis.default service that will use in this tutorial. Of course you can use any cache layer you want, feel free to adapt the code to your needs.

1. Logging of the authentication failures

The first step is to log the authentication failures. It can be done via the security.authentication.failure event. To catch this event we can attach an event subscriber to it.
Create the following class Fail2BanSubscriber.php, for example in a Subscriber directory. (the path can differ depending on if you are using Symfony Flex or not).

<?php declare(strict_types=1);

namespace App\Subscriber; // or AppBundle\Subscriber;

use Predis\ClientInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
//use Symfony\Component\Security\Core\Event\AuthenticationFailureEvent;
use Symfony\Component\Security\Core\AuthenticationEvents;

/**
 * Original idea : https://www.mon-code.net/article/89/Proteger-son-authentification-symfony3-contre-les-attaques-par-force-brute
 */
class Fail2BanSubscriber implements EventSubscriberInterface
{
    public const KEY_PREFIX = 'login_failures_';
    public const FAIL_TTL_HOUR = 1; // used in Twig

    /**
     * @var ClientInterface
     */
    protected $redis;
    protected $request;

    public function __construct(RequestStack $requestStack, $sncRedisDefault)
    {
        $this->request = $requestStack->getCurrentRequest();
        $this->redis = $sncRedisDefault;
    }

    public static function getSubscribedEvents(): array
    {
        return [
            AuthenticationEvents::AUTHENTICATION_FAILURE => 'onAuthenticationFailure',
        ];
    }

    /**
     * Client IP shouldn't be empty but better to test. We don't need the event as we
     * don't use the username that caused the failure. If you want it, pass the
     * argument "AuthenticationFailureEvent" to this function like below.
     */
    public function onAuthenticationFailure(/*AuthenticationFailureEvent $authenticationFailureEvent*/): void
    {
        $ip = $this->request->getClientIp();
        if (!$ip) {
            return;
        }

        $key = self::KEY_PREFIX.$ip;
        $this->redis->incr($key); // increment the failed login counter for this ip
        $this->redis->expire($key, self::FAIL_TTL_HOUR*3600); // refresh the cache key TTL
    }
}

The code is straightforward:

  • We get the client IP.
  • We use the Redis incr function to increment the counter bound the IP or to initialize it if it isn't set yet.
  • We refresh (or set) the expire time of the counter to one hour.

To test locally, you can do inside your Redis client: (or you can also pass these commands as arguments to the redis-cli program)

  • $ get login_failures_127.0.0.1
  • $ ttl login_failures_127.0.0.1
  • $ del login_failures_127.0.0.1

That's it for or our first subscriber class. βœ…

2. Preventing a banned user from login

Now that we have logged failed login attempts, we must prevent the user to do other attempts if he has reached the threshold we have set. So we need another subscriber for this purpose. This time, it will be bound to the kernel.request event with a low priority. The goal will be to prevent the user to do another login/password try. (but he will still be able to access the login form). Create a PostFail2BanSubscriber.php file in the same directory as the previous subscriber:

<?php declare(strict_types=1);

namespace App\Subscriber; // or AppBundle\Subscriber

use Predis\ClientInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Router;
use Symfony\Component\Routing\RouterInterface;

/**
 * Deny access to login if the user IP is banned.
 */
class PostFail2BanSubscriber implements EventSubscriberInterface
{
    public const MAX_LOGIN_FAILURE_ATTEMPTS = 10; // Used in Twig
    private const PRIORIY = 10;

    // @see https://symfony.com/doc/current/reference/configuration/security.html#check-path
    private const LOGIN_ROUTE = 'user_security_check'; // change you security route name here

    private $router;
    private $logger;

    /**
     * @var ClientInterface
     */
    private $redis;

    public function __construct(RouterInterface $router, LoggerInterface $logger, $sncRedisDefault)
    {
        $this->router = $router;
        $this->logger = $logger;
        $this->redis = $sncRedisDefault;
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['checkLoginBan', self::PRIORIY]
        ];
    }

    public function checkLoginBan(GetResponseEvent $event): void
    {
        // Only post routes, 1 check is for typehint on $this->router
        $request = $event->getRequest();
        if (!$this->router instanceof Router || !$request->isMethod(Request::METHOD_POST)) {
            return;
        }

        // Only for the login check route
        $route = $this->router->matchRequest($request)['_route'] ?? '';
        if (self::LOGIN_ROUTE !== $route) {
            return;
        }

        $ip = $request->getClientIp();
        $key = Fail2BanSubscriber::KEY_PREFIX.$ip;
        if ((int) $this->redis->get($key) >= self::MAX_LOGIN_FAILURE_ATTEMPTS) {
            $this->logger->critical(sprintf('IP %s banned due to %d login attempts failures.', $ip, self::MAX_LOGIN_FAILURE_ATTEMPTS));

            throw new HttpException(Response::HTTP_TOO_MANY_REQUESTS);
        }
    }
}

Some explanations. The first tests are to verify the two following conditions:

  • It's a post request...
  • ...to the login check action

After those checks we get the Redis key like we built it in the previous subscriber. And then we check that the counter we get isn't equal or greater than the threshold we have set (10 in this case). If it is the case we will throw an exception with a specific 429 error code (HTTP_TOO_MANY_REQUESTS) that will be useful in the next step. (We also log the error just before raising the exception)
That's it for or our second subscriber class. βœ…
But we are not done yet because we don't want to display the default error page to the user. But before getting there, we must activate the two subscribers we've just created.

3. Activate the subscribers

If using Symfony4 and Flex, all you should have to add to your services.yaml is:
(If you have a clever way configure this, please let me know πŸ˜‰. But it seems that the @snc_redis.default can't be auto-wired.)

# Symfony4 Flex: config/services.yaml
services:
    _defaults:
        bind:
            $sncRedisDefault: '@snc_redis.default'

If not using Symfony Flex and the services auto-wiring, add this to your services.yml file.

# Symfony3 or 4 without autowiring: app/config/services.yml
AppBundle\Subscriber\Fail2BanSubscriber:
    class: AppBundle\Subscriber\Fail2BanSubscriber
    arguments: ['@request_stack', '@snc_redis.default']
    tags:
        - { name: kernel.event_subscriber }

AppBundle\Subscriber\PostFail2BanSubscriber:
    class: AppBundle\Subscriber\PostFail2BanSubscriber
    arguments: ['@router', '@logger', '@snc_redis.default']
    tags:
        - { name: kernel.event_subscriber }

4. Displaying the custom error page

Symfony has a nice feature that allows to display custom error pages, depending of the error code. To do so, we have to create a Twig template with the error code we want to customize the output. So, in our case, create a Twig tempalte with the following content:

{# Symfony4 Flex: templates/bundles/TwigBundle/Exception/error429.html.twig #}
{# Symfony3: app/Resources/TwigBundle/views/Exception/error429.html.twig #}

{% extends 'layout.html.twig' %}

{% block meta_title %}Error {{ status_code }}, too many login attempts!{% endblock meta_title %}

{% block content %}
    <h1>Security warning</h1>

    <h2>Too many login attempts!</h2>

    <p>
        Too many failed login attempts detected. Your tried to login {{ constant('App\\Subscriber\\PostFail2BanSubscriber::MAX_LOGIN_FAILURE_ATTEMPTS') }}
        times without success. You will <b>NOT</b> be able to login during <b>{{ constant('App\\Subscriber\\Fail2BanSubscriber::FAIL_TTL_HOUR') }}</b> hour(s) since now.
        If you think it's a bug please contact us.
    </p>

    <p class="text-center">
        <b>If you have lost your password please use the following form:</b><br/><br/>
        <a href="{# path('user_resetting_request_'~locale) #}"  type="button" class="btn btn-primary btn-lg">
            <span>Request a new password</span>
        </a>
    </p>
{% endblock %}

In this template, we use the constants we defined in our subscriber classes so there aren't hard-coded values. If you have a "Lost password?" page then change the route and uncomment the block. To test this template locally, browse /_error/429.html with your development environment. That's it! βœ…

Card image cap

I will receive this log on my #slack log channel when one will reach ten failed attempts.

I hope you liked this post. This was the very first article of this new blog I am rewriting from scratch with Symfony 4.2. A tantΓ΄t!
You can try this feature on one of my side project where it has been implemented. Follow the white rabbit: πŸ˜‰


 Check out the demo  More on the web

» Published in "A week of Symfony 621" (19/25 november 2018).


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment/contact me )

  • Report any error/typo.
  • Report something that could be improved.
  • Like and retweet!
  • Follow me on Twitter
  • Subscribe to the RSS feed.

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil