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

Publié le 16/11/2018 • Mis à jour le 06/01/2019

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 mon kiki ! 😎

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; // 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
    }
}

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; // 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, 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;
        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);
        }
    }
}

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 Symfony4 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).

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

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

# 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. 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 :

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

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 4.2. 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 Symfonyque 628" (7 au 13 janvier 2019).


» A vous de jouer !

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

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Likez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.

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

COil