Protéger son authentification symfony3 contre les attaques par force brute

Tout formulaire d'authentification est susceptible d'être attaqué par un hacker (enfin plus un robot) via une brute force. Cette attaque consiste à tester des couples login/password jusqu'à en trouver un qui permet de s'authentifier. Certains site ne font rien contre cette attaque, d'autre utilisent des captcha ou autres solutions toutes aussi ennuyeuses pour l'utilisateur et enfin les bons élèves mettent en place un mécanisme de bannissement comme le tant utilisé Fail2Ban coté OS.
C'est cette dernière option, que j'ai mis en place sur mon projet en cours avec Symfony3 et que je vais détailler ici. Avec quelques ajustements, il est facile de faire fonctionner ce code sur du Symfony2 également.

Mon besoin est de bannir l'adresse ip en train de s'authentifier après 5 tentatives ratées pour une durée de 1h. Je ne peux pas empécher le robot de venir sur mon formulaire, mais avec 5 tentatives par heure, les possibilités qu'il trouve un couple login/password fonctionnel se réduisent. Je ne veux pas non plus empécher l'utilisateur derrière l'IP d'utiliser le site (dans le cas où ce ne serait pas un robot), donc je le redirigerai sur une page d'erreur une fois le seuil de 5 tentatives ratées franchi s'il tente d'accéder au formulaire d'authentification.

Je vais utiliser 2 Events de Symfony3 pour arriver à mon but ainsi que le nouveau composant cache disponible depuis symfony 3.1.

1. Enregistrer une tentative d'authentification ratée

Symfony depuis sa version 2.0 expose l'event "security.authentication.failure" qui permet de savoir quand l'authentification a échoué. J'utilise donc cet event pour enregistrer en cache cette évenement afin qu'il persist d'une requête à une autre.

<?php
 
namespace ACME\CoreBundle\Event\Subscriber;
 
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\AuthenticationEvents;
use Symfony\Component\Security\Core\Event\AuthenticationFailureEvent;
 
class PreFail2Ban implements EventSubscriberInterface
{
    private $redisAdapter;
 
    public function __construct(RedisAdapter $redisAdapter)
    {
        $this->redisAdapter = $redisAdapter;
    }
 
    public static function getSubscribedEvents()
    {
        return [
            AuthenticationEvents::AUTHENTICATION_FAILURE => 'onAuthenticationFailure',
        ];
    }
 
    public function onAuthenticationFailure(AuthenticationFailureEvent $event)
    {
        $username = $event->getAuthenticationToken()->getUsername();
        $fail2ban = $this->redisAdapter->getItem($this->request->getClientIp());
        $list = ($fail2ban->isHit()) ? $fail2ban->get() : [];
        if (!isset($list[$username])) {
            $list[$username] = 1;
        } else {
            $list[$username]++;
        }
 
        $fail2ban->set($list);
 
        $fail2ban->expiresAt(new \DateTime('+ 1 hour'));
        $this->redisAdapter->save($fail2ban);
    }
}


services:
    core.event.subscriber.pre_fail2ban:
        class: ACME\CoreBundle\Event\Subscriber\PreFail2Ban 
        arguments:
            - "@cache.app"
        tags:
            - { name: kernel.event_subscriber }
 


J'utilise l'IP de l'utilisateur comme clé de cache. Je stocke le login et le nombre de tentative par login pour avoir des stats sur l'utilisation de la feature, la partie log de ces informations n'est pas détaillée ici. Je définis également une nouvelle date d'expiration pour ma clé de cache.
Mon tableau au cache ressemble à ça:

[
    'login1' => 1,
    'login2' => 3,
    ...
]

2. Bannir une fois le quota atteint

Cette fois je vais écouter l'event "kernel.request" mais je vais mettre une priorité de 10 pour que mon EventSubscriber passe avant le Firewall. Ce que je veux c'est uniquement rediriger l'utilisateur sur une autre page que la page d'authentification s'il atteint le quota de 5 echecs.

<?php
 
namespace ACME\CoreBundle\Event\Subscriber;
 
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
 
class PostFail2Ban implements EventSubscriberInterface
{
    const MAX_LOGIN_FAILURE_ATTEMPTS = 5;
 
    private $request;
    private $router;
    private $redisAdapter;
 
    public function __construct(
        RequestStack $requestStack,
        RequestMatcherInterface $router,
        RedisAdapter $redisAdapter
    ) {
        $this->request = $requestStack->getCurrentRequest();
        $this->router = $router;
        $this->redisAdapter = $redisAdapter;
    }
 
 
    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::REQUEST => ['beforeFirewall', 10]
        ];
    }
 
    public function beforeFirewall(GetResponseEvent $event)
    {
        $request = $event->getRequest();
        if ($request->isMethod(Request::METHOD_POST)) {
            $routeInfos = $this->router->matchRequest($request);
            if (isset($routeInfos['_route']) && $routeInfos['_route'] === 'acme_security_login') {
                $fail2ban = $this->redisAdapter->getItem($request->getClientIp());
 
                if ($fail2ban->isHit() && self::MAX_LOGIN_FAILURE_ATTEMPTS < array_sum($fail2ban->get())) {
                    throw new HttpException(
                        429,
                        'Too many failed authentication, please come back in an hour from now.'
                    );
                }
            }
        }
    }
}

services:
    core.event.subscriber.post_fail2ban:
        class: ACME\CoreBundle\Event\Subscriber\PostFail2Ban 
        arguments:
            - "@request_stack"
            - "@router"
            - "@cache.app"
        tags:
            - { name: kernel.event_subscriber }
 

Pour éviter d'interroger le cache à chaque requête, je vérifie que je suis sur une méthode POST et que la route qui correspond à la requête est ma route du formulaire d'authentification. Si ces conditions sont réunies, je récupère mes données en cache via la clé en utilisant l'IP et je compte le nombre de tentative. S'il y en a eu suffisament, je lève une exception qui sera attrapée par un listener de Twig et transformée en une belle page explicative.
La clé de cache expirera automatiquement une heure après ce qui permettra à l'utilisateur ou au robot d'accéder une nouvelle fois au formulaire d'authentification.

Bien sûr les 2 classes peuvent être réunis en une seule, je les ai juste séparé pour faciliter la lecture du code.
Le bon coté, c'est que je n'ai pas eu à modifier la configuration de la securité de symfony dans mon projet, cette solution peut donc être facilement mise en place avec un impact réduit sur le code existant et je pense doit être compatible avec FosUserBundle ou autre.

Il y a 4 commentaires.

Ecrit par bruno le 13 déc. 2017

Bonjour, si l'attaquant a un grand nombre d'IP (genre un botnet), ce type de sécurité tombe, un bon complément me semble donc une limitation de tentative/heure sur chaque login.

Réponse de Ulrich le 17 déc. 2017

En effet Bruno, cette solution ne permet pas de se protéger d'un botnet. Quelques adaptation sont à faire dans le code.

Ecrit par fabien le 2 nov. 2017

Top ! Merci pour ce tuto.

Ecrit par METHODE le 30 août 2017

Bonjour, C'est génial, je vais l'utiliser. Cdt

Ajouter un commentaire