Implementing a login fail2ban like system for Symfony with Redis

Published on 2018-11-16 • Modified on 2020-10-17

In this post, we will see how to implement a fail2ban 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! 😎

Warning

This article is outdated. As of Symfony 5.2, you can use the login throttling feature of the security component. If you use Symfony 5.1 or below, it could still be useful. 😉

Configuration and prerequisite

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 caching 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 a 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;

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;

use function Symfony\Component\String\u;

/**
 * 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

    public function __construct(
        private readonly RequestStack $requestStack,
        private readonly CacheItemPoolInterface $cacheItemPool,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            LoginFailureEvent::class => 'onAuthenticationFailure', // since 5.4
        ];
    }

    private function getRequest(): Request
    {
        $request = $this->requestStack->getCurrentRequest();
        if (!$request instanceof Request) {
            throw new \RuntimeException('No request.');
        }

        return $request;
    }

    /**
     * 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(): void
    {
        $ip = u($this->getRequest()->getClientIp())->trim();
        if ($ip->isEmpty()) {
            return;
        }

        $key = self::KEY_PREFIX.$ip->toString();

        /** @noinspection PhpUnhandledExceptionInspection */
        $cacheitem = $this->cacheItemPool
            ->getItem($key)
            ->expiresAfter(self::FAIL_TTL_HOUR * 3600) // // refresh the cache key TTL
        ;

        // increment the failed login counter for this ip
        $cacheValue = $cacheitem->get();
        $cacheitem->set($cacheValue === null ? 1 : $cacheValue + 1);
    }
}

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 service. ✅

2. Preventing a banned user from login

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

<?php

declare(strict_types=1);

namespace App\Subscriber;

use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
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.
 *
 * @see php bin/console debug:event-dispatcher kernel.request
 */
class PostFail2BanSubscriber implements EventSubscriberInterface
{
    public const MAX_LOGIN_FAILURE_ATTEMPTS = 10; // Used in Twig
    private const PRIORIY = 9;

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

    public function __construct(
        private readonly RouterInterface $router,
        private readonly LoggerInterface $logger,
        private readonly CacheItemPoolInterface $cacheItemPool,
    ) {
    }

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

    /**
     * The parameters type is "GetResponseEvent" before Symfony 5.
     *
     * @noinspection PhpUnhandledExceptionInspection
     */
    public function checkLoginBan(RequestEvent $event): void
    {
        // Only post routes, first 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;
        $cacheitem = $this->cacheItemPool->getItem($key);
        /** @var int $cacheValue */
        $cacheValue = $cacheitem->get() ?? 0;

        if ($cacheValue >= 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 as 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 event subscribers we've just created.

3. Activate the subscribers' services

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

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

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

# Symfony 3 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 displaying custom error pages, depending on 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 an error429.html.twig Twig template with the following content:

{# Symfony 4/5 Flex: templates/bundles/TwigBundle/Exception/error429.html.twig #}
{# Symfony 3: app/Resources/TwigBundle/views/Exception/error429.html.twig #}

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

{% block title %}Error {{ status_code }}, too many login attempts!{% endblock 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! ✅

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

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

I hope you liked this post and that it may be useful to you. The goal is obviously to take what you need from it and adapt it to your needs and your projects. This was the very first article of this new blog I am rewriting from scratch using Symfony. See you!
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).

  Work with me!


Call to action

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

Thank you for reading! And see you soon on Strangebuzz! 😉

COil