10000 [Security] add password rehashing capabilities by nicolas-grekas · Pull Request #31153 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Security] add password rehashing capabilities #31153

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Doctrine\Common\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

Expand All @@ -25,7 +26,7 @@
* @author Fabien Potencier <fabien@symfony.com>
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
*/
class EntityUserProvider implements UserProviderInterface
class EntityUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private $registry;
private $managerName;
Expand Down Expand Up @@ -107,6 +108,22 @@ public function supportsClass($class)
return $class === $this->getClass() || is_subclass_of($class, $this->getClass());
}

/**
* {@inheritdoc}
*/
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
{
$class = $this->getClass();
if (!$user instanceof $class) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
}

$repository = $this->getRepository();
if ($repository instanceof PasswordUpgraderInterface) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A silent failure seems odd here. Maybe log something in debug that the user will not be saved because the repository doesn't support it? At least that way when people are trying to figure out why the feature doesn't appear to be working there is something in the logs to tell them why.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A hard failure would be even more odd: you don't want your users not being able to log in when password upgrades cannot happen. They must be opportunistic to me. Logging why not!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree a hard failure would be incorrect here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure anymore about the logger: it would be noisy for ppl that just don't want to implement password upgrades for some reason.
Better document this: if you need pwd upgrade, you must implement the interface.

$repository->upgradePassword($user, $newEncodedPassword);
}
}

private function getObjectManager()
{
return $this->registry->getManager($this->managerName);
Expand Down
5 changes: 3 additions & 2 deletions src/Symfony/Bridge/Doctrine/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"symfony/property-access": "~3.4|~4.0",
"symfony/property-info": "~3.4|~4.0",
"symfony/proxy-manager-bridge": "~3.4|~4.0",
"symfony/security": "~3.4|~4.0",
"symfony/security": "^4.4",
"symfony/expression-language": "~3.4|~4.0",
"symfony/validator": "~3.4|~4.0",
"symfony/translation": "~3.4|~4.0",
Expand All @@ -48,7 +48,8 @@
"phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0",
"symfony/dependency-injection": "<3.4",
"symfony/form": "<4.3",
"symfony/messenger": "<4.2"
"symfony/messenger": "<4.2",
"symfony/security-core": "<4.4"
},
"suggest": {
"symfony/form": "",
Expand Down
7 changes: 7 additions & 0 deletions src/Symfony/Component/Security/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
CHANGELOG
=========

4.4.0
-----

* Added `MigratingPasswordEncoder`
* Added methods `PasswordEncoderInterface::needsRehash()` and `UserPasswordEncoderInterface::needsRehash()`
* Added and implemented `PasswordUpgraderInterface`

4.3.0
-----

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Symfony\Component\Security\Core\Exception\AuthenticationServiceException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
Expand Down Expand Up @@ -54,9 +55,15 @@ protected function checkAuthentication(UserInterface $user, UsernamePasswordToke
throw new BadCredentialsExcept 741A ion('The presented password cannot be empty.');
}

if (!$this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) {
$encoder = $this->encoderFactory->getEncoder($user);

if (!$encoder->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) {
throw new BadCredentialsException('The presented password is invalid.');
}

if ($this->userProvider instanceof PasswordUpgraderInterface && method_exists($encoder, 'needsRehash') && $encoder->needsRehash($user->getPassword())) {
$this->userProvider->upgradePassword($user, $encoder->encodePassword($presentedPassword, $user->getSalt()));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gives this method two responsibilies: checking the password and upgrading it if needed. Maybe this should be done at some higher -orchistration- level?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean the checkAuthentication method? I've no better idea, can you elaborate?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the checkAuthentication method.
So instead of doing this inside the checkAuthentication do this in the code that calls checkAuthentication, so move it one level up to decouple the behaviours:

if ($provider->checkAuthentication(...)) {
    $provider->upgradePassword(...);
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have a look but I think what misses in your example is the encoder: the "if" must involve it before calling upgrade. Yet the encoder is internal detail of the provider.

Copy link
Member Author
@nicolas-grekas nicolas-grekas May 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

encoder is internal detail of the provider

I confirm - and so is the cleartext password btw. Same for guard.
Deadend to me.

Copy link
Contributor
@rpkamp rpkamp May 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to me it could be moved to \Symfony\Component\Security\Core\Authentication\Provider\UserAuthenticationProvider. That way it's part of the template method so all AuthenticationProviders can benefit from it, instead of being part of a particular implementation.

public function authenticate(TokenInterface $token)
{

    // ...

    try {
        $this->userChecker->checkPreAuth($user);
        $this->checkAuthentication($user, $token);
        if ($this instanceof PasswordUpgradingAuthenticationProvider) {
            // optional: if ($this->needsPasswordRehash($user, $token)) {
            $this->upgradeUserPassword($user, $token);
            // optional: }
        }
        $this->userChecker->checkPostAuth($user);
    } catch (BadCredentialsException $e) {
        if ($this->hideUserNotFoundExceptions) {
            throw new BadCredentialsException('Bad credentials.', 0, $e);
        }
        throw $e;
    }

and then \Symfony\Component\Security\Core\Authentication\Provider\DaoAuthenticationProvider would implement PasswordUpgradingAuthenticationProvider:

class DaoAuthenticationProvider extends UserAuthenticationProvider implements PasswordUpgradingAuthenticationProvider
{

   // ...

   public function upgradeUserPassword(UserInterface $user, UsernamePasswordToken $token): void
   {
      if ($this->userProvider instanceof PasswordUpgraderInterface && method_exists($encoder, 'needsRehash') && $encoder->needsRehash($user->getPassword())) {
          $this->userProvider->upgradePassword($user, $encoder->encodePassword($token->getCredentials(), $user->getSalt()));
      }
   }
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: if ($this->needsPasswordRehash($user, $token)) {

yes but no :) this is not optional: this is part of the critical lifecycle of password migrations.
Not all auth providers have a concept of encoders. Only those should know and care about migrations. Said another way, your initial issue was SRP for the method, this proposal is moving the issue somewhere else. Let's say there is no SRP issue in the first place and everything is fine: checkAuthentication is the correct place to wire opportunistic password upgrades to me.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My issue was with CQS, not SRP. My issue with SRP was in the naming of the ChainPasswordEncoder.

My issue here is still CQS, and I think it would be solved by my proposal.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry it wouldn't, see my objections about encoders. See also the guard interface.
Opportunistic password upgrades don't align to CQS here to me.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But please submit a PR on my fork if I'm missing the point :)

}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ abstract class BasePasswordEncoder implements PasswordEncoderInterface
{
const MAX_PASSWORD_LENGTH = 4096;

/**
* {@inheritdoc}
*/
public function needsRehash(string $encoded): bool
{
return false;
}

/**
* Demerges a merge password and salt string.
*
Expand Down
14 changes: 13 additions & 1 deletion src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,19 @@ private function createEncoder(array $config)
private function getEncoderConfigFromAlgorithm($config)
{
if ('auto' === $config['algorithm']) {
$config['algorithm'] = SodiumPasswordEncoder::isSupported() ? 'sodium' : 'native';
$encoderChain = [];
// "plaintext" is not listed as any leaked hashes could then be used to authenticate directly
foreach (['sodium', 'native', 'pbkdf2', $config['hash_algorithm']] as $algo) {
if ('sodium' !== $algo || SodiumPasswordEncoder::isSupported()) {
$config['algorithm'] = $algo;
$encoderChain[] = $this->createEncoder($config);
}
}

return [
'class' => MigratingPasswordEncoder::class,
'arguments' => $encoderChain,
];
}

switch ($config['algorithm']) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ public function encodePassword($raw, $salt)
*/
public function isPasswordValid($encoded, $raw, $salt)
{
return !$this->isPasswordTooLong($raw) && $this->comparePasswords($encoded, $this->encodePassword($raw, $salt));
return '$' !== substr($encoded, 0, 1) && !$this->isPasswordTooLong($raw) && $this->comparePasswords($encoded, $this->encodePassword($raw, $salt));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Core\Encoder;

/**
* Hashes passwords using the best available encoder.
* Validates them using a chain of encoders.
*
* /!\ F41A Don't put a PlaintextPasswordEncoder in the list as that'd mean a leaked hash
* could be used to authenticate successfully without knowing the cleartext password.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class MigratingPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface
{
private $bestEncoder;
private $extraEncoders;

public function __construct(PasswordEncoderInterface $bestEncoder, PasswordEncoderInterface ...$extraEncoders)
{
$this->bestEncoder = $bestEncoder;
$this->extraEncoders = $extraEncoders;
}

/**
* {@inheritdoc}
*/
public function encodePassword($raw, $salt)
{
return $this->bestEncoder->encodePassword($raw, $salt);
}

/**
* {@inheritdoc}
*/
public function isPasswordValid($encoded, $raw, $salt)
{
if ($this->bestEncoder->isPasswordValid($encoded, $raw, $salt)) {
return true;
}

if (!$this->bestEncoder->needsRehash($encoded)) {
return false;
}

foreach ($this->extraEncoders as $encoder) {
if ($encoder->isPasswordValid($encoded, $raw, $salt)) {
return true;
}
}

return false;
}

/**
* {@inheritdoc}
*/
public function needsRehash(string $encoded): bool
{
return $this->bestEncoder->needsRehash($encoded);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,12 @@ public function isPasswordValid($encoded, $raw, $salt)

return \strlen($raw) <= self::MAX_PASSWORD_LENGTH && password_verify($raw, $encoded);
}

/**
* {@inheritdoc}
*/
public function needsRehash(string $encoded): bool
{
return password_needs_rehash($encoded, $this->algo, $this->options);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* PasswordEncoderInterface is the interface for all encoders.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @method bool needsRehash(string $encoded)
*/
interface PasswordEncoderInterface
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,6 @@ public function encodePassword($raw, $salt)
*/
public function isPasswordValid($encoded, $raw, $salt)
{
return !$this->isPasswordTooLong($raw) && $this->comparePasswords($encoded, $this->encodePassword($raw, $salt));
return '$' !== substr($encoded, 0, 1) && !$this->isPasswordTooLong($raw) && $this->comparePasswords($encoded, $this->encodePassword($raw, $salt));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,20 @@ public function isPasswordValid($encoded, $raw, $salt)

throw new LogicException('Libsodium is not available. You should either install the sodium extension, upgrade to PHP 7.2+ or use a different encoder.');
}

/**
* {@inheritdoc}
*/
public function needsRehash(string $encoded): bool
{
if (\function_exists('sodium_crypto_pwhash_str_needs_rehash')) {
return \sodium_crypto_pwhash_str_needs_rehash($encoded, $this->opsLimit, $this->memLimit);
}

if (\extension_loaded('libsodium')) {
return \Sodium\crypto_pwhash_str_needs_rehash($encoded, $this->opsLimit, $this->memLimit);
}

throw new LogicException('Libsodium is not available. You should either install the sodium extension, upgrade to PHP 7.2+ or use a different encoder.');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,14 @@ public function isPasswordValid(UserInterface $user, $raw)

return $encoder->isPasswordValid($user->getPassword(), $raw, $user->getSalt());
}

/**
* {@inheritdoc}
*/
public function needsRehash(UserInterface $user, string $encoded): bool
{
$encoder = $this->encoderFactory->getEncoder($user);

return method_exists($encoder, 'needsRehash') && $encoder->needsRehash($encoded);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* UserPasswordEncoderInterface is the interface for the password encoder service.
*
* @author Ariel Ferrandini <arielferrandini@gmail.com>
*
* @method bool needsRehash(UserInterface $user, string $encoded)
*/
interface UserPasswordEncoderInterface
{
Expand Down
18 changes: 17 additions & 1 deletion src/Symfony/Component/Security/Core/User/ChainUserProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
*
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
*/
class ChainUserProvider implements UserProviderInterface
class ChainUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private $providers;

Expand Down Expand Up @@ -104,4 +104,20 @@ public function supportsClass($class)

return false;
}

/**
* {@inheritdoc}
*/
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
{
foreach ($this->providers as $provider) {
if ($provider instanceof PasswordUpgraderInterface) {
try {
$provider->upgradePassword($user, $newEncodedPassword);
} catch (UnsupportedUserException $e) {
// ignore: password upgrades are opportunistic
}
}
}
}
}
38 changes: 37 additions & 1 deletion src/Symfony/Component/Security/Core/User/LdapUserProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\ConnectionException;
use Symfony\Component\Ldap\Exception\ExceptionInterface;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
Expand All @@ -24,7 +25,7 @@
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Charles Sarrazin <charles@sarraz.in>
*/
class LdapUserProvider implements UserProviderInterface
class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private $ldap;
private $baseDn;
Expand All @@ -34,6 +35,7 @@ class LdapUserProvider implements UserProviderInterface
private $uidKey;
private $defaultSearch;
private $passwordAttribute;
private $entry;

public function __construct(LdapInterface $ldap, string $baseDn, string $searchDn = null, string $searchPassword = null, array $defaultRoles = [], string $uidKey = null, string $filter = null, string $passwordAttribute = null)
{
Expand Down Expand Up @@ -89,6 +91,11 @@ public function loadUserByUsername($username)
} catch (InvalidArgumentException $e) {
}

if (null !== $this->entry) {
// Keep $entry around when called from upgradePassword()
$this->entry = $entry;
}

return $this->loadUser($username, $entry);
}

Expand All @@ -112,6 +119,35 @@ public function supportsClass($class)
return 'Symfony\Component\Security\Core\User\User' === $class;
}

/**
* {@inheritdoc}
*/
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
}

if (null === $this->passwordAttribute) {
return;
}

try {
// Tell loadUserByUsername() to keep the $entry around
$this->entry = true;

if ($user->isEqualTo($this->loadUserByUsername($user->getUsername())) && \is_object($this->entry)) {
$this->entry->setAttribute($this->passwordAttribute, [$newEncodedPassword]);
$this->ldap->getEntryManager()->update($this->entry);
$user->setPassword($newEncodedPassword);
}
} catch (ExceptionInterface $e) {
// ignore failed password upgrades
} finally {
$this->entry = null;
}
}

/**
* Loads a user from an LDAP entry.
*
Expand Down
Loading
0