Migrating users to a secure hashing algorithm in Symfony
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 toold_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 theAppBundle\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.