Implémenter un mécanisme de type fail2ban pour Symfony avec Redis

Publié le 16/11/2018 • Actualisé le 17/10/2020

Dans cet article nous allons voir comment implémenter un mécanisme simple de type fail2ban pour Symfony. Le principe va être de logger les erreurs d'identification pour une IP donnée et d'interdire toute autre tentative dés lors qu'on aura détecté un trop grand nombre d'échecs. Une fois ce seuil atteint, une page d'erreur personnalisée sera affichée à l'utilisateur. ⏹ C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

Avertissement

Cet article est obsolète. Depuis Symfony 5.2 vous pouvez utiliser la fonctionnalité login throttling du composant sécurité. Si vous utilisez Symfony 5.1 ou une version précédente, il pourra toujours vous être utile. 😉

Configuration et pré-requis

Je vais considérer ici que vous êtes déjà familier avec Symfony et l'installation d'un bundle avec composer. Cet article a été écrit en utilisant les composants suivants :

Le bundle SncRedis doit être configuré avec un client par défaut, il nous fournira le service @snc_redis.default que nous utiliserons dans ce tutoriel. Bien sûr vous pouvez utilisez n'importe quel autre mécanisme de cache. Vous êtes invités à adapter le code à vos besoins.

1. Capture des échecs d'identification

La première étape est de capturer les erreurs d'identification. Ça peut être fait grâce à l'événement security.authentication.failure. Pour intercepter celui-ci, nous pouvons lui associer un "event subscriber". Créez la classe suivante Fail2BanSubscriber.php, par exemple dans le répertoire Subscriber de votre répertoire src/. (le chemin peut-être différent selon que vous utilisez Symfony Flex ou pas)

<?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);
    }
}

Le code est explicite :

  • On récupère l'IP du client.
  • On utilise la fonction Redis incr pour incrémenter le compteur d'erreurs lié à cette IP ou pour l'initialiser s'il n'existe pas encore.
  • On rafraichit la durée de vie de ce cache à une heure.

Pour tester localement, vous pouvez utiliser le client Redis (vous pouvez aussi passer ces commandes comme des arguments à l'exécutable redis-cli)

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

C'est tout bon pour notre premier service "subscriber". ✅

2. Empêcher un utilisateur banni de s'identifier

Maintenant que nous avons enregistré les échecs d'identification, nous devons empêcher l'utilisateur de faire d'autres tentatives de connexion s'il a atteint le seuil critique que nous avons fixé. Pour ce faire nous avons besoin d'un autre service. Cette fois, il va être lié à l'événement kernel.request avec une priorité basse. Le but va être d'intercepter la soumission du formulaire avant que la requête n'atteigne le controlleur de vérification du login / mot de passe. (le formulaire de login sera donc toujours accessible). Créez un fichier PostFail2BanSubscriber.php dans le même répertoire que le précédent service que nous avons créé.

<?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);
        }
    }
}

Quelques explications. Les premiers tests sont pour vérifier les conditions suivantes :

  • C'est une requête http de type POST
  • vers l'action de vérification du login

Après ces vérifications, nous construisons la clé de cache Redis comme nous l'avons fait dans le précédent service. Puis nous vérifions si le compteur d'erreurs associé n'est pas égal ou supérieur à la limite que nous avons fixée. (10 dans ce cas) Si c'est le cas, nous allons lever une exception avec un code d'erreur spécifique 429 (HTTP_TOO_MANY_REQUESTS) qui va nous être utile dans l'étape suivante. (Nous loggons aussi l'erreur juste avant de lever l'exception)
Voilà pour le deuxième service "subscriber". ✅
Mais nous n'avons pas encore fini car nous ne voulons pas afficher la page d'erreur par défaut à l'utilisateur. Mais avant d'en arriver là, nous devons activer les deux services que nous venons de créer.

3. Activation des services "event subscribers"

Si vous utilisez Symfony 4 / 5 et Flex, tout ce que vous devriez ajouter à votre fichier services.yaml est : (Si vous connaissez une manière plus intelligente de configurer cela, faites le moi savoir. 😉 Mais il semble que le service @snc_redis.default ne puisse pas être "auto-câblé" (🇬🇧 auto-wired).

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

Si vous n'utilisez pas Flex ni l'auto-wiring, ajoutez ceci à votre fichier services.yml.

# 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. Afficher la page d'erreur personnalisée

Symfony dispose d'une élégante fonctionnalité permettant d'afficher des pages d'erreur personnalisées dépendantes des codes d'erreur. Pour ce faire, nous devons créer un template Twig avec le code d'erreur dont nous voulons personnaliser l'affichage. Créez un template Twig error429.html.twig avec le contenu suivant :

{# 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 %}

Dans ce template, nous utilisons les constantes que nous avons définies dans nos services pour qu'il n'y ait pas de valeurs en dur. Si vous avez une page "Mot de passe oublié" alors changez le nom de cette route et dé-commentez le bloc relatif. Pour tester cette page locallement, accédez dans votre navigateur à /_error/429.html en utilisant votre environnement de développement. Et voilà ! ✅

Je vais recevoir ce log sur mon channel #slack quand quelqu'un sera banni.

Je vais recevoir ce log sur mon channel #slack quand quelqu'un sera banni.

J'espère que vous avez aimé cet article et qu'il pourra vous être utile. Le but bien sûr est de pouvoir vous en inspirer pour l'adapter à vos besoins et vos projets. C'était le tout premier de ce nouveau blog que je ré-écrit de zéro avec Symfony. A tantôt!
Vous pouvez tester cette fonctionnalité sur l'un de mes projets ou elle a été implémentée. Suivez le lapin blanc : 🐇


 Check out the demo  More on the web

» Publié dans "Une semaine Symfonique 628" (7 au 13 janvier 2019).

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil