Migrating users to a secure hashing algorithm in Symfony

This article explains how to gracefully convert your insecurely encrypted passwords to a secure method (using Bcrypt for instance).

Your app may use an old and unsecure hashing algorithm for storing passwords, like MD5 (without salt).

This article explains how to convert your insecurely encrypted passwords to a secure method (using Bcrypt for instance).

To solve the problem, we will make an on-the-fly conversion when a user successfully logs in, and make use of Symfony's EncoderAwareInterface interface, login listener and use some not very well known parameters in security.yml.

Authentication before the migration

If you app is using simple MD5 encrypted passwords, the security.yml file will look like this to make the user authentication work in Symfony:

# app/config/security.yml
security:
    encoders:
        AppBundle\Entity\User:
            algorithm: md5
            encode_as_base64: false
            iterations:       1

In this article, we will suppose that the User entity looks like this:

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

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

/**
 * @ORM\Table(name="app_users")
 * @ORM\Entity(repositoryClass="AppBundle\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=64)
     */
    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;
    }

    // ...
}

Prepare the database

We are going to separate the password columns for each encoding method:

  • Rename the password column to old_password.
  • Add a new column named password, that will contain the newly encoded password.
  • Make both of these columns nullable.

The new User entity will now look like this:

# src/AppBundle/Entity/User.php
class User implements UserInterface, EncoderAwareInterface, \Serializable
{
    // ...

    /**
     * @ORM\Column(type="string", length=64, nullable=true)
     */
    private $oldPassword;

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

    public function getPassword()
    {
        return null === $this->password ? $this->oldPassword : $this->password;
    }

    // ...
}

Make authentication work with both hashing algorithms

We are going to configure two encoders:

  • The new encoder, which will be the default one for the User entity (hence the AppBundle\Entity\User key).
  • The one used for users that haven't migrated yet (using MD5), called legacy_encoder.

Define these encoders in Symfony's security.yml file:

# app/config/security.yml
security:
    encoders:
        AppBundle\Entity\User:
            algorithm: bcrypt
        legacy_encoder:
            algorithm:        md5
            encode_as_base64: false
            iterations:       1

In order to tell Symfony which encoder to use depending on the user that is logging in, we are going to use the EncoderAwareInterface on the User entity, with the getEncoderName() method:

# src/AppBundle/Entity/User.php
<?php

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

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

    /**
     * Tells whether user uses the legacy password encoding or the new one
     *
     * @return boolean
     */
    public function hasLegacyPassword()
    {
        return null !== $this->oldPassword;
    }

    /**
     * {@inheritDoc}
     */
    public function getEncoderName()
    {
        if ($this->hasLegacyPassword()) {
            // User is configured with a legacy password, make use of the legacy encoder
            // configured in security.yml
            return 'legacy_encoder';
        }

        // User is configured with the default password system, make use of the default encoder
        return null;
    }
}

When a user entity implements this interface, Symfony will call the getEncoderName() method to determine which encoder to use when the password is being checked. If the method returns null, the default encoder is used.

All users can now log in, whether they are using the new algorithm or not.

Add a login listener that makes the migration

We are going to attach a listener to the Symfony security.interactive_login event that is raised when the user successfully logs in.

Declare first the listener in the services.yml file:

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

Create the listener:

# src/AppBundle/EventListener/LoginListener.php
<?php

namespace AppBundle\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();

        // Migrate the user to the new hashing algorithm if is using the legacy one
        if ($user->hasLegacyPassword()) {
            // Credentials can be retrieved thanks to the false value of
            // the erase_credentials parameter in security.yml
            $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();
        }

        // We don't need any more credentials
        $token->eraseCredentials();
    }
}

This listener updates the user's password only in case the user is still using the legacy password system.

In order to re-encode the password, we need the plain password the user has entered, which is not available by default in the authentication token provided by InteractiveLoginEvent object. To make it available, make the following change to the security.yml file:

# app/config/security.yml
security:
    erase_credentials: false
    # ...

As your users log in, they will progressively update the database, making it more secure with time. Once most users have logged in, you can remove the old_password column and implement a "Forgot password?" feature for those who wouldn't have migrated.