Skip to content

Commit

Permalink
feature #31597 [Security] add MigratingPasswordEncoder (nicolas-grekas)
Browse files Browse the repository at this point in the history
This PR was merged into the 4.4 branch.

Discussion
----------

[Security] add MigratingPasswordEncoder

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        | -

Split from #31153: the proposed `MigratingPasswordEncoder` is able to validate password using a chain of encoders, and encodes new them using the best-provided algorithm.

This chained encoder is used when the "auto" algorithm is configured. This is seamless for 4.3 app.

Commits
-------

765f14c [Security] add MigratingPasswordEncoder
  • Loading branch information
fabpot committed Jun 4, 2019
2 parents 8d359b2 + 765f14c commit ec9159e
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
-----

* Added method `needsRehash()` to `PasswordEncoderInterface` and `UserPasswordEncoderInterface`
* Added `MigratingPasswordEncoder`

4.3.0
-----
Expand Down
12 changes: 11 additions & 1 deletion src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php
Expand Up @@ -85,7 +85,17 @@ 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 ([SodiumPasswordEncoder::isSupported() ? 'sodium' : 'native', 'pbkdf2', $config['hash_algorithm']] as $algo) {
$config['algorithm'] = $algo;
$encoderChain[] = $this->createEncoder($config);
}

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

switch ($config['algorithm']) {
Expand Down
@@ -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.
*
* /!\ 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);
}
}
@@ -0,0 +1,73 @@
<?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\Tests\Encoder;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Encoder\MigratingPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder;
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;

class MigratingPasswordEncoderTest extends TestCase
{
public function testValidation()
{
$bestEncoder = new NativePasswordEncoder(4, 12000, 4);

$extraEncoder = $this->getMockBuilder(TestPasswordEncoderInterface::class)->getMock();
$extraEncoder->expects($this->never())->method('encodePassword');
$extraEncoder->expects($this->never())->method('isPasswordValid');
$extraEncoder->expects($this->never())->method('needsRehash');

$encoder = new MigratingPasswordEncoder($bestEncoder, $extraEncoder);

$this->assertTrue($encoder->needsRehash('foo'));

$hash = $encoder->encodePassword('foo', 'salt');
$this->assertFalse($encoder->needsRehash($hash));

$this->assertTrue($encoder->isPasswordValid($hash, 'foo', 'salt'));
$this->assertFalse($encoder->isPasswordValid($hash, 'bar', 'salt'));
}

public function testFallback()
{
$bestEncoder = new NativePasswordEncoder(4, 12000, 4);

$extraEncoder1 = $this->getMockBuilder(TestPasswordEncoderInterface::class)->getMock();
$extraEncoder1->expects($this->any())
->method('isPasswordValid')
->with('abc', 'foo', 'salt')
->willReturn(true);

$encoder = new MigratingPasswordEncoder($bestEncoder, $extraEncoder1);

$this->assertTrue($encoder->isPasswordValid('abc', 'foo', 'salt'));

$extraEncoder2 = $this->getMockBuilder(TestPasswordEncoderInterface::class)->getMock();
$extraEncoder2->expects($this->any())
->method('isPasswordValid')
->willReturn(false);

$encoder = new MigratingPasswordEncoder($bestEncoder, $extraEncoder2);

$this->assertFalse($encoder->isPasswordValid('abc', 'foo', 'salt'));

$encoder = new MigratingPasswordEncoder($bestEncoder, $extraEncoder2, $extraEncoder1);

$this->assertTrue($encoder->isPasswordValid('abc', 'foo', 'salt'));
}
}

interface TestPasswordEncoderInterface extends PasswordEncoderInterface
{
public function needsRehash(string $encoded): bool;
}

0 comments on commit ec9159e

Please sign in to comment.