Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save thibaut-decherit/fb041311b6e387132a8077062acd6ded to your computer and use it in GitHub Desktop.
Save thibaut-decherit/fb041311b6e387132a8077062acd6ded to your computer and use it in GitHub Desktop.
Symfony - Password rehash on authentication if auto encoder settings changed & legacy password hashes migration

Disclaimer

Password rehash on login if needed is natively handled by Symfony since 4.4. See https://symfony.com/blog/new-in-symfony-4-4-password-migrations.

The legacy password hashes migration part might still be of use though, but beware of password shucking: If the legacy hash is not salted and is present in data breaches from other platforms, overhashing might have little to no effect.

Password rehash on authentication if auto encoder settings changed

config/packages/security.yaml

security:
  password_hashers:
    App\Entity\User:
      # 'argon2id' is forced because 'auto' seems to have reverted to bcrypt in Symfony 5.3 but argon2id is more
      # resilient, especially against GPU-based cracking.

      algorithm: argon2id
      # Tweak these two values according to server hardware. It is recommended that server response doesn't take more
      # than 1 second during "normal" login.
      # Feel free to ignore the 1-second limit for important passwords (e.g. admin account), you would then have to
      # create a dedicated encoder for admin users, with higher values for these two settings.
      # See https://symfony.com/doc/current/security/named_encoders.html
      # Bear in mind that login will take longer if Symfony has to rehash the password
      # (see https://symfony.com/blog/new-in-symfony-4-4-password-migrations) because of algorithm/options change.
      # This situation should therefore not be considered as a "normal" login and could take more than 1 second.
      memory_cost: 128000  # Default is 65536
      time_cost: 10  # Default is 4

src/Repository/UserRepository.php

<?php

namespace App\Repository;

use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;

/**
 * @method User|null find($id, $lockMode = null, $lockVersion = null)
 * @method User|null findOneBy(array $criteria, array $orderBy = null)
 * @method User[]    findAll()
 * @method User[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, User::class);
    }

    /**
     * Called automatically on login to persist an up-to-date hash of the user's password in case it needed a rehash.
     * A rehash is "needed" if algorithm or options in config/packages/security.yaml are different from current hash.
     * Works with any algorithm supported by the "auto" hasher (Bcrypt, Argon2i and Argon2id).
     *
     * See https://symfony.com/blog/new-in-symfony-4-4-password-migrations.
     *
     * @param User $user
     * @param string $newHashedPassword The new hash with up-to-date algorithm/options.
     * @return void
     */
    public function upgradePassword(User $user, string $newHashedPassword): void
    {
        $user->setPassword($newHashedPassword);
        $this->getEntityManager()->flush($user);
    }
}

The following is optional, required if you have passwords hashed with legacy algorithms (e.g. SHA-1). You have two options.

Warning: Since 4.4 Symfony has a native password migrator, consider using it instead of a custom implementation. Symfony does not have a native overhashing feature though. Warning: This implementation upgrades password hashes to Bcrypt and is therefore NOT up-to-date. Consider modifying it to upgrade hashes to Argon2id instead.

See https://www.michalspacek.com/upgrading-existing-password-hashes to get the picture.

First Option (the good one): Overhash all legacy hashes with an up-to-date algorithm then update the hash to a pure up-to-date one on login

Bcrypt is assumed to be an up-to-date algorithm in the following code. Let's say you have an user database with SHA-1 hashed passwords.

The objective is twofold: First, use a command to overhash all these hashes using bcrypt. Second, be able to authenticate users with overhashed password then replace the bcrypt(sha1($plainPassword)) hash with a pure bcrypt($plainPassword) one.

src/Command/OverhashLegacyPasswordsCommand.php

<?php

namespace App\Command;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

/**
 * Class OverhashLegacyPasswordsCommand
 * @package App\Command
 */
class OverhashLegacyPasswordsCommand extends Command
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var UserPasswordEncoderInterface
     */
    private $passwordEncoder;

    public function __construct(EntityManagerInterface $em, UserPasswordEncoderInterface $passwordEncoder)
    {
        parent::__construct();
        $this->em = $em;
        $this->passwordEncoder = $passwordEncoder;
    }

    protected function configure()
    {
        $this
            ->setName('app:overhash-legacy-passwords')
            ->setDescription('
            Overhashes legacy password hashes.
            You MUST specify the legacy algorithm which will be recorded in database.
            This legacy algorithm will then be used during the login process to hash the plainPassword prior to password
            validation.
            For example, assuming you run this command on an user database storing SHA-1 hashes
            
            Disclaimer: This parameter is NOT used to target specific hashes to overhash. This command will overhash
            ALL non-bcrypt hashes in the user database table, EVEN if they do not match the specified algorithm.
            ')
            ->addArgument('legacyHashAlgorithm', InputArgument::REQUIRED);
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     * @return int|void|null
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $limit = 50;
        $offset = 0;
        $hasResults = true;
        $legacyHashAlgorithm = mb_strtolower($input->getArgument('legacyHashAlgorithm'), 'UTF-8');

        while ($hasResults) {
            $users = $this->em->getRepository('AppBundle:User')->findBy([], null, $limit, $offset);

            foreach ($users as $user) {
                $this->overhashPasswordIfLegacy($user, $legacyHashAlgorithm);
            }

            if (empty($users)) {
                $hasResults = false;
            }

            $offset += $limit;
        }
    }

    /**
     * @param User $user
     * @param string $legacyHashAlgorithm
     */
    private function overhashPasswordIfLegacy(User $user, string $legacyHashAlgorithm): void
    {
        $currentPasswordHash = $user->getPassword();

        // IF password hash is already a bcrypt hash, do nothing.
        if (preg_match('/^\$[2]y\$\d{1,2}\$/', $currentPasswordHash)) {
            return;
        }

        /*
         * Overhashes currentPasswordHash with (hopefully up-to-date) algorithm specified in
         * config.yml security.encoders parameter.
         */
        $user->setPassword($this->passwordEncoder->encodePassword($user, $currentPasswordHash));
        $user->setLegacyPasswordHashAlgorithm($legacyHashAlgorithm);

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

Modify setPassword() in src/AppBundle/Entity/User.php

/**
 * @param string $password
 * @return User
 */
public function setPassword(string $password): User
{
    $this->password = $password;

    /*
     * setPassword() is called because the password has just been hashed with the algorithm currently specified in
     * config.yml security.encoders parameter, so legacy support is no longer needed and must be removed.
     */
    $this->setLegacyPasswordHashAlgorithm(null);

    /*
     * Optional: If the legacy hash algorithm implementation requires a hash, you can set it to null here.
     * This is assuming you are now using a self salting password hash algorithm (e.g. bcrypt or argon2)
     */

    return $this;
}

Add in src/Entity/User.php

/**
 * @var string
 *
 * @ORM\Column(type="string", length=25, nullable=true)
 */
private $legacyPasswordHashAlgorithm;

/**
 * @return null|string
 */
public function getLegacyPasswordHashAlgorithm(): ?string
{
    return $this->legacyPasswordHashAlgorithm;
}

/**
 * @param string|null $legacyPasswordHashAlgorithm
 * @return User
 */
public function setLegacyPasswordHashAlgorithm(?string $legacyPasswordHashAlgorithm): User
{
    $this->legacyPasswordHashAlgorithm = $legacyPasswordHashAlgorithm;
    return $this;
}

Modify src/EventListener/OnAuthPasswordRehashIfNeeded.php

/**
 * On authentication checks if user's password needs rehash in case of bcrypt cost change
 * WARNING : Will rehash password even if new cost is lower than current hash cost
 *
 * @param InteractiveLoginEvent $event
 */
public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
{
    $user = $event->getAuthenticationToken()->getUser();
    $options = ["cost" => $this->cost];
    $currentHashedPassword = $user->getPassword();

    $hasLegacyPasswordHashAlgorithm = !is_null($user->getLegacyPasswordHashAlgorithm()); // <== NEW

    if (password_needs_rehash($currentHashedPassword, PASSWORD_BCRYPT, $options) || $hasLegacyPasswordHashAlgorithm) { // <== MODIFIED
        $em = $this->entityManager;
        $plainPassword = $event->getRequest()->request->get('_password');

        $user->setPassword(
            $this->passwordEncoder->encodePassword($user, $plainPassword)
        );

        $em->flush();
    }
}

Modify src/Security/LoginFormAuthenticator.php

/**
 * @param mixed $credentials
 * @param UserInterface $user
 * @return bool
 */
public function checkCredentials($credentials, UserInterface $user)
{
    $plainPassword = $this->overhashPasswordIfLegacy($user, $credentials['password']); // <== MODIFIED

    if ($this->passwordEncoder->isPasswordValid($user, $plainPassword)) { // <== MODIFIED
        return true;
    }

    return false;
}

Add in src/Security/LoginFormAuthenticator.php

/**
 * Overhashes plainPassword if user's current stored password hash is an overhash (e.g. legacy SHA1 password hash
 * hashed by bcrypt)
 *
 * @param UserInterface $user
 * @param string $plainPassword
 * @return string
 */
private function overhashPasswordIfLegacy(UserInterface $user, string $plainPassword): string
{
    $legacyPasswordHashAlgorithm = $user->getLegacyPasswordHashAlgorithm();

    /* 
     * Add legacy algorithm(s) here.
     * Optional: Add legacy salt if relevant to your implementation, you can get it from $user.
     */
    switch ($legacyPasswordHashAlgorithm) {
        case 'sha1':
            $plainPassword = sha1($plainPassword);
            break;
        default:
            break;
    }

    return $plainPassword;
}

Second Option (the not so good one): Update the hash on login

Let's say you have this in app/config/security.yml:

security:
  encoders:
    App\Entity\User:
      algorithm: sha1

Change the main encoder and add the legacy one:

security:
  encoders:
    App\Entity\User:
      algorithm: bcrypt
      cost: '%bcrypt_cost%'
    user_legacy_encoder:
      algorithm: sha1

src/Entity/User must implement this interface: EncoderAwareInterface

Add these methods to src/Entity/User:

    /**
     * Tells whether user's password is encoded with bcrypt or not.
     *
     * @return boolean
     */
    public function hasBcryptPassword(): bool
    {
        return preg_match('/^\$[2]y\$\d{1,2}\$/', $this->getPassword());
    }

    /**
     * Returns an encoder based on user's password stored in database.
     *
     * @return string|null
     */
    public function getEncoderName(): ?string
    {
        // If user has a legacy password, legacy encoder must be used instead of the default one.
        if (!$this->hasBcryptPassword()) {
            return 'user_legacy_encoder';
        }

        // Returning null means using default encoder.
        return null;
    }

Change src/EventListener/OnAuthPasswordRehashIfNeeded.php

<?php

namespace App\EventListener;

use Doctrine\ORM\EntityManager;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;

/**
 * Class OnAuthPasswordRehashIfNeeded
 * @package App\EventListener
 */
class OnAuthPasswordRehashIfNeeded
{
    /**
     * @var EntityManager
     */
    private $entityManager;

    /**
     * @var UserPasswordEncoderInterface
     */
    private $passwordEncoder;

    /**
     * @var int
     */
    private $cost;

    /**
     * OnAuthPasswordRehashIfNeeded constructor.
     * @param EntityManager $entityManager
     * @param UserPasswordEncoderInterface $passwordEncoder
     * @param int $cost
     */
    public function __construct(EntityManager $entityManager, UserPasswordEncoderInterface $passwordEncoder, int $cost)
    {
        $this->entityManager = $entityManager;
        $this->passwordEncoder = $passwordEncoder;
        $this->cost = $cost;
    }

    /**
     * On authentication checks if user's password needs rehash in case of encoder or bcrypt cost change.
     * WARNING : Will rehash password even if new cost is lower than current hash cost.
     *
     * @param InteractiveLoginEvent $event
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
     */
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
    {
        $user = $event->getAuthenticationToken()->getUser();
        $options = ["cost" => $this->cost];
        $currentHashedPassword = $user->getPassword();

        if (password_needs_rehash($currentHashedPassword, PASSWORD_BCRYPT, $options)) {
            $em = $this->entityManager;
            $plainPassword = $event->getRequest()->request->get('_password');

            // Bcrypt hash includes its own salt.
            $user->setSalt(null);

            /*
             * Required to trick $user->getEncoderName() into returning default encoder (bcrypt) instead of legacy one
             * to ensure the correct encoder is used below.
             */
            $user->setPassword('$2y$13$');

            $user->setPassword(
                $this->encoderFactory->getEncoder($user)->encodePassword($plainPassword, $user->getSalt())
            );

            $em->flush();
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment