Image d'illustration -Migrer nos mots de passe vers un algorithme plus sécurisé sous Symfony

Dans la continuité de mon précédent article sur le hashage des mots de passe, votre application (sous Symfony ou autre) utilise peut-être MD5 ou BCrypt.

Cet article a pour but de vous expliquer comment convertir vos mots de passe encryptés de manière non sécurisée vers une méthode plus sécurisée (BCrypt ou Argon2 utilisé ici) sous Symfony (Flex).

Pour résoudre le problème, nous allons faire une conversion à la volée lorsque l'utilisateur se connecte. Toutefois, si votre application est critique, il peut être préférable de passer par une étape intermédiaire consistant à immédiatement faire un hash des versions MD5 de vos mots de passe. Lors de la première connexion de vos utilisateurs, vous devrez faire un hash MD5 puis BCrypt ou Argon2 pour vérifier le mot de passe et une fois cela fait, à la connexion de votre utilisateur, faire un hash direct vers l'algorithme sécurisé. La méthode est la même qu'ici avec une étape supplémentaire pour le double hashage à l'authentification.


Pour rester simple, nous allons imaginer que vous avez un hash sécurisé comme BCrypt ou que votre application n'est pas très sensible.

Pour résoudre ce problème, nous allons donc faire la conversion des mots de passe à la volée lorsqu'un utilisateur s'est authentifié. Pour cela, nous allons utiliser l'interface de Symfony EncoderAwareInterface, un écouteur de l'évènement login et mettre en place des paramètres un peu méconnus de security.yaml.

Authentification avec la migration

Si votre application utilise un simple hashage MD5 pour authentifier vos utilisateurs, le fichier security.yaml ressemble à quelque chose comme cela :

# config/packages/security.yaml
security:  
    encoders:
        App\Entity\User:
            algorithm: md5
            encode_as_base64: false
            iterations: 1

 

Si votre application utilise BCrypt, security.yaml ressemble à cela :

# config/packages/security.yaml
security:  
    encoders:
        App\Entity\User:
            algorithm: bcrypt
            cost: 11


Dans cet article, nous allons supposer que la classe de l'entité User est celle-ci :

# src/Entity/User.php
<?php  
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;  
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Table(name="app_user")
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 */
class User implements UserInterface, \Serializable  
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=25, unique=true)
     */
    private $username;

    /**
     * @ORM\Column(type="string", length=32)
     */
    private $password;

    public function getId()
    {
        return $this->id;
    }

    public function getUsername()
    {
        return $this->username;
    }

    public function setUsername($username)
    {
        $this->username = $username;

        return $this;
    }

    public function getPassword()
    {
        return $this->password;
    }

    public function setPassword($password)
    {
        $this->password = $password;

        return $this;
    }

    public function getSalt()
    {
        return null;
    }

    // ...
}

 

Apportons les modifications nécessaires à cette entité

Comme vous pouvez le voir ci-dessus, l'entité User a des propriétés dont les colonnes ont une taille limitée. Pour rappel, ceci n'est pas une bonne pratique si vous voulez une bonne portabilité de votre application, notamment si vous utilisez SQLite pour votre environnement de test.

La colonne de la propriété $password est limitée à 32 caractères (taille d'un hash MD5).

# src/Entity/User.php
// ...
    /**
     * @ORM\Column(type="string", length=32)
     */
    private $password;
//...


Un hash BCrypt a quand à lui une longueur de 50 à 72 octets et Argon2 génère également une chaîne de longueur variable. Celle-ci commençant par les informations de calcul de l'algorithme comme ceci : $argon2i$v=19$m=1024,t=2,p=HASHARGON2ICI. Afin d'avoir une bonne portabilité et pouvoir stocker nos nouveaux hash, modifions l'entité en supprimant cette longueur :

# src/Entity/User.php
// ...
    /**
     * @ORM\Column(type="string")
     */
    private $password;
//...

 

Procédez alors avec la migration de votre base de données afin d'appliquer ce changement.
 

Apportons les modifications nécessaires pour faire fonctionner les deux algorithmes de hashage

Nous allons tout d'abord configurer les deux encodeurs :

  • - le nouvel encodeur, qui sera celui par défaut pour l'entité User (d'où la clé App\Entity\User)
  • - celui utilisé pour les utilisateurs que nous n'avons pas encore migré (utilisant MD5), que nous allons appeler legacy_encoder.

Nous allons également ici indiquer au composant de sécurité qu'il ne doit pas effacer les identifiants via l'option erase_credentials à false. Normalement, ce paramètre est à true car une fois l'utilisateur authentifié, nous ne voulons pas garder ses identifiants en mémoire par sécurité. Or nous en avons besoin pour l'écouteur d'évènement que nous allons mettre en place.

Définissons cela dans le fichier security.yaml de Symfony :

# config/packages/security.yaml
security:
    # Ne pas effacer les identifiants car nous en avons besoin nous le ferons
    erase_credentials: false
    encoders:
        App\Entity\User:
            algorithm: argon2i
            # mémoire maximum (en KiB) pouvant être utilisée pour calculer le hash Argon2
            memory_cost: 1024
            # nombre de fois que l'algorithme Argon2 doit être exécuté
            time_cost: 2
            # nombre de threads à utiliser pour calculer le hash Argon2
            threads: 2
        legacy_encoder:
            algorithm: md5
            encode_as_base64: false
            iterations: 1

 

Indiquons maintenant à Symfony quel encodeur utiliser en fonction de l'utilisateur. Pour cela, nous allons utiliser EncoderAwareInterface sur l'entité User avec la méthode getEncoderName() :

# src/Entity/User.php
<?php

// ...
use Symfony\Component\Security\Core\Encoder\EncoderAwareInterface;

class User implements UserInterface, EncoderAwareInterface, \Serializable  
{
    // ...

    /**
     * Indique si l'utilisateur utilise l'encodage de mot de passe legacy ou le nouveau
     * 
     * @return boolean
     */
    public function hasLegacyPassword(): bool
    {
        return null !== $this->oldPassword;
    }

    /**
     * {@inheritDoc}
     */
    public function getEncoderName()
    {
        if ($this->hasLegacyPassword()) {
            // L'utilisateur est configuré avec un mot de passe legacy, utiliser l'encodeur legacy
            // configured in security.yaml
            return 'legacy_encoder';
        }

        // L'utilisateur est configuré avec l'encodage par défaut (ici Argon2i), utiliser l'encodeur par défaut
        return null;
    }
}


Lorsqu'une entité utilisateur implémente cette interface, Symfony va appeler la méthode getEncoderName() pour déterminer quel encodeur utiliser lorsque le mot de passe est vérifié.
Si la méthode retourne null, l'encodeur par défaut est utilisé.

Tous nos utilisateurs peuvent désormais se connecter, qu'ils utilisent le nouvel algorithme ou pas.

Ajoutons un écouteur d'évènement sur notre login pour faire la migration

Nous allons attacher un écouteur (listener) à l'évènement Symfony security.interactive_login qui est levé lorsque l'utilisateur est correctement authentifié.

Déclarons tout d'abord notre listener dans le fichier services.yaml :

# config/services.yaml
services:  
    app.login_listener:
        class: App\EventListener\LoginListener
        tags:
            - { name: kernel.event_listener, event: security.interactive_login }
        arguments:
            - "@security.encoder_factory"
            - "@doctrine.orm.entity_manager"

 

Créons l'écouteur :

# src/EventListener/LoginListener.php
<?php

namespace App\EventListener;

use Doctrine\Common\Persistence\ObjectManager;  
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;  
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;

class LoginListener  
{
    private $encoderFactory;
    private $om;

    public function __construct(EncoderFactoryInterface $encoderFactory, ObjectManager $om)
    {
        $this->encoderFactory = $encoderFactory;
        $this->om = $om;
    }

    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
    {
        $user = $event->getAuthenticationToken()->getUser();
        $token = $event->getAuthenticationToken();

        // Migre l'utilisateur vers le nouvel algorithme de hashage s'il utilise l'ancien
        if ($user->hasLegacyPassword()) {
            // Les identifiants peuvent être récupérés grâce à la valeur false
            // du paramètre erase_credentials de security.yaml
            $plainPassword = $token->getCredentials();

            $user->setOldPassword(null);
            $encoder = $this->encoderFactory->getEncoder($user);

            $user->setPassword(
                $encoder->encodePassword($plainPassword, $user->getSalt())
            );

            $this->om->persist($user);
            $this->om->flush();
        }

        // Nous n'avons plus besoins des identifiants
        $token->eraseCredentials();
    }
}

Cet écouteur met à jour le mot de passe de l'utilisateur seulement si l'utilisateur utilise l'ancien algorithme.

Pour pouvoir faire cela, nous avons besoin du mot de passe en clair qui a été saisi par l'utilisateur. Celui-ci n'est normalement pas disponible par défaut dans le jeton d'authentification fourni par l'objet InteractiveLoginEvent.
C'est pour cela que nous avons ajouté précédemment dans notre fichier security.yaml l'option erase_credentials en la définissant à false.

# config/security.yaml
security:  
    erase_credentials: false
    # ...

Au fur et à mesure que vos utilisateurs vont se connecter, ils vont mettre à jour la base de données, la randant plus sécurisée avec le temps. Une fois que la plupart des utilisateurs se sont connecté, vous pouvez supprimer la colonne et la propriété  old_password, désactiver notre code et enlever erase_credentials. Les utilisateurs n'ayant pas migré devront alors passer par la fonctionnalité "Mot de passe perdu ?" de votre application.


2