Migrating users to new password hashing algorithms in Symfony

Sina Sina 23.11.2016

Along with the continous increase in computing power, the relative difficulty of breaking hashing algorithms decreases. It might occur that during the lifetime of a project, one algorithm becomes obsolete, or due to various reasons, the necessity of migrating to another algorithm becomes inevitable.

There are two major issues regarding such a migration. First, although the new users passwords can be easily encrypted using the new method, the plaintext form of already hashed passwords is not available for re-encryption. This can be overcome, by migrating those passwords gradually, when the respective users log in the next time. From the first solution, arises the need for the application to handle both the previous and the new hashing algorithms.

Following the official Symfony documentation, we can define dynamic password encoders in app/config/security.yml similar to below:

# app/config/security.yml

security:
    encoders:
        old:
            algorithm: sha512
            iterations: 5000
        new:
            algorithm: bcrypt
            cost: 15

The hashing algorithm which has been used to encrypt each user's password, should be stored as well, and can be used to choose the encoder accordingly:

// src/Acme/UserBundle/Entity/User.php

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

class User implements EncoderAwareInterface
{
    private $password;

    /** @ORM\Column(type="string") */
    private $encoder = 'new';

    //  Setters and Getters

    public function getEncoderName()
    {
        return $this->encoder;
    }
}

Up to this point, if newly registered users are configured to have new as their encoder, and through a database update, encoder is set to old for existing users, login functionality will work seamlessly for both groups.

ALTER TABLE user ADD encoder VARCHAR(255) NOT NULL;
UPDATE user SET encoder = 'old';

However, to migrate the existing users passwords to the new algorithm, a listener which can capture successful login events, encapsulating the plaintext password, can be defined:

# src/Acme/UserBundle/Resources/config/services.yml

services:
    userBundle.userListener:
        class: Acme\UserBundle\EventListener\UserListener
        arguments:
            - '@doctrine.orm.default_entity_manager'
            - '@security.password_encoder'
        tags:
            - name: kernel.event_listener
              event: security.interactive_login

And implemented in the following way:

# src/Acme/UserBundle/EventListener/UserListener.php

use Doctrine\ORM\EntityManager;

use Symfony\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Security\Core\Encoder\UserPasswordEncoder;

class UserListener
{
    private $entityManager;
    private $passwordEncoder;

    public function __construct(
        EntityManager $entityManager,
        UserPasswordEncoder $passwordEncoder
    )
    {
        $this->entityManager = $entityManager;
        $this->passwordEncoder = $passwordEncoder;
    }

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

        if ($user->getEncoder() === 'old') {
            $user->setEncoder('new');
            $password = $request->request->get('_password') ;
            $user->setPassword(
                $this->passwordEncoder->encodePassword($user, $password)
            );

            $this->entityManager->flush();
        }
    }
}

Additionally, a second way to log into websites is usually the forgot password dialog. Depending on the implementation in Symfony this does not trigger the security.interactive_login event. In this case make sure to upgrade the password as well. If you are using FOSUserBundle, this can be done by upgrading the password algorithm to the most current whenever the setPlainPassword function is called.

class User extends BaseUser
{
    // ... other code

    const CURRENT_PASSWORD_ALGORITHM = 'bcrypt';

    public function setPlainPassword($password)
    {
        $this->encoder = self::CURRENT_PASSWORD_ALGORITHM;

        return parent::setPlainPassword($password);
    }
}

In this example, the current and the intented hashing algorithms are respectively sha512 and bcrypt. However, naturally, the choice of algorithms depends on the requirements of the project.

If the algorithm is already unsafe (md5, sha1, ...) you should take more drastic steps and not slowly migrate the passwords to the new format, given that probably many users will rarely login or never at all if they are disabled. Again you can make use here of the security.interactive_login command and redirect users with a deleted password hash to the password reset page.

Finally, it is very important to note that since hashing algorithms are irreversible, one must take extreme care, and thoroughly test the migration functionality on test servers before deploying to production.