feature #34139 [Security] Add migrating encoder configuration (chalasr)
This PR was merged into the 4.4 branch.
Discussion
----------
[Security] Add migrating encoder configuration
| Q | A
| ------------- | ---
| Branch? | 4.4
| Bug fix? | no
| New feature? | yes
| Deprecations? | no
| Tickets | -
| License | MIT
| Doc PR |
Commits
-------
80955be45d
[Security] Add migrating encoder configuration
This commit is contained in:
commit
d688a79b5a
@ -4,6 +4,7 @@ CHANGELOG
|
|||||||
4.4.0
|
4.4.0
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
* Added `migrate_from` option to encoders configuration.
|
||||||
* Added new `argon2id` encoder, undeprecated the `bcrypt` and `argon2i` ones (using `auto` is still recommended by default.)
|
* Added new `argon2id` encoder, undeprecated the `bcrypt` and `argon2i` ones (using `auto` is still recommended by default.)
|
||||||
* Deprecated the usage of "query_string" without a "search_dn" and a "search_password" config key in Ldap factories.
|
* Deprecated the usage of "query_string" without a "search_dn" and a "search_password" config key in Ldap factories.
|
||||||
* Marked the `SecurityDataCollector` class as `@final`.
|
* Marked the `SecurityDataCollector` class as `@final`.
|
||||||
|
@ -394,6 +394,10 @@ class MainConfiguration implements ConfigurationInterface
|
|||||||
->beforeNormalization()->ifString()->then(function ($v) { return ['algorithm' => $v]; })->end()
|
->beforeNormalization()->ifString()->then(function ($v) { return ['algorithm' => $v]; })->end()
|
||||||
->children()
|
->children()
|
||||||
->scalarNode('algorithm')->cannotBeEmpty()->end()
|
->scalarNode('algorithm')->cannotBeEmpty()->end()
|
||||||
|
->arrayNode('migrate_from')
|
||||||
|
->prototype('scalar')->end()
|
||||||
|
->beforeNormalization()->castToArray()->end()
|
||||||
|
->end()
|
||||||
->scalarNode('hash_algorithm')->info('Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms.')->defaultValue('sha512')->end()
|
->scalarNode('hash_algorithm')->info('Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms.')->defaultValue('sha512')->end()
|
||||||
->scalarNode('key_length')->defaultValue(40)->end()
|
->scalarNode('key_length')->defaultValue(40)->end()
|
||||||
->booleanNode('ignore_case')->defaultFalse()->end()
|
->booleanNode('ignore_case')->defaultFalse()->end()
|
||||||
|
@ -512,6 +512,10 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
|
|||||||
return new Reference($config['id']);
|
return new Reference($config['id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($config['migrate_from'] ?? false) {
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
// plaintext encoder
|
// plaintext encoder
|
||||||
if ('plaintext' === $config['algorithm']) {
|
if ('plaintext' === $config['algorithm']) {
|
||||||
$arguments = [$config['ignore_case']];
|
$arguments = [$config['ignore_case']];
|
||||||
|
@ -287,6 +287,7 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
'JMS\FooBundle\Entity\User3' => [
|
'JMS\FooBundle\Entity\User3' => [
|
||||||
'algorithm' => 'md5',
|
'algorithm' => 'md5',
|
||||||
@ -299,6 +300,7 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
||||||
'JMS\FooBundle\Entity\User5' => [
|
'JMS\FooBundle\Entity\User5' => [
|
||||||
@ -320,6 +322,7 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
]], $container->getDefinition('security.encoder_factory.generic')->getArguments());
|
]], $container->getDefinition('security.encoder_factory.generic')->getArguments());
|
||||||
}
|
}
|
||||||
@ -348,6 +351,7 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
'JMS\FooBundle\Entity\User3' => [
|
'JMS\FooBundle\Entity\User3' => [
|
||||||
'algorithm' => 'md5',
|
'algorithm' => 'md5',
|
||||||
@ -360,6 +364,7 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
||||||
'JMS\FooBundle\Entity\User5' => [
|
'JMS\FooBundle\Entity\User5' => [
|
||||||
@ -401,6 +406,7 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
'JMS\FooBundle\Entity\User3' => [
|
'JMS\FooBundle\Entity\User3' => [
|
||||||
'algorithm' => 'md5',
|
'algorithm' => 'md5',
|
||||||
@ -413,6 +419,7 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
||||||
'JMS\FooBundle\Entity\User5' => [
|
'JMS\FooBundle\Entity\User5' => [
|
||||||
@ -430,9 +437,14 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
]], $container->getDefinition('security.encoder_factory.generic')->getArguments());
|
]], $container->getDefinition('security.encoder_factory.generic')->getArguments());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEncodersWithBCrypt()
|
public function testMigratingEncoder()
|
||||||
{
|
{
|
||||||
$container = $this->getContainer('bcrypt_encoder');
|
if (!($sodium = SodiumPasswordEncoder::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) {
|
||||||
|
$this->markTestSkipped('Argon2i algorithm is not supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$container = $this->getContainer('migrating_encoder');
|
||||||
|
|
||||||
$this->assertEquals([[
|
$this->assertEquals([[
|
||||||
'JMS\FooBundle\Entity\User1' => [
|
'JMS\FooBundle\Entity\User1' => [
|
||||||
'class' => 'Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder',
|
'class' => 'Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder',
|
||||||
@ -449,6 +461,7 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
'JMS\FooBundle\Entity\User3' => [
|
'JMS\FooBundle\Entity\User3' => [
|
||||||
'algorithm' => 'md5',
|
'algorithm' => 'md5',
|
||||||
@ -461,6 +474,67 @@ abstract class CompleteConfigurationTest extends TestCase
|
|||||||
'memory_cost' => null,
|
'memory_cost' => null,
|
||||||
'time_cost' => null,
|
'time_cost' => null,
|
||||||
'threads' => null,
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
|
],
|
||||||
|
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
||||||
|
'JMS\FooBundle\Entity\User5' => [
|
||||||
|
'class' => 'Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder',
|
||||||
|
'arguments' => ['sha1', false, 5, 30],
|
||||||
|
],
|
||||||
|
'JMS\FooBundle\Entity\User6' => [
|
||||||
|
'class' => 'Symfony\Component\Security\Core\Encoder\NativePasswordEncoder',
|
||||||
|
'arguments' => [8, 102400, 15],
|
||||||
|
],
|
||||||
|
'JMS\FooBundle\Entity\User7' => [
|
||||||
|
'algorithm' => 'argon2i',
|
||||||
|
'hash_algorithm' => 'sha512',
|
||||||
|
'key_length' => 40,
|
||||||
|
'ignore_case' => false,
|
||||||
|
'encode_as_base64' => true,
|
||||||
|
'iterations' => 5000,
|
||||||
|
'cost' => null,
|
||||||
|
'memory_cost' => 256,
|
||||||
|
'time_cost' => 1,
|
||||||
|
'threads' => null,
|
||||||
|
'migrate_from' => ['bcrypt'],
|
||||||
|
],
|
||||||
|
]], $container->getDefinition('security.encoder_factory.generic')->getArguments());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncodersWithBCrypt()
|
||||||
|
{
|
||||||
|
$container = $this->getContainer('bcrypt_encoder');
|
||||||
|
|
||||||
|
$this->assertEquals([[
|
||||||
|
'JMS\FooBundle\Entity\User1' => [
|
||||||
|
'class' => 'Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder',
|
||||||
|
'arguments' => [false],
|
||||||
|
],
|
||||||
|
'JMS\FooBundle\Entity\User2' => [
|
||||||
|
'algorithm' => 'sha1',
|
||||||
|
'encode_as_base64' => false,
|
||||||
|
'iterations' => 5,
|
||||||
|
'hash_algorithm' => 'sha512',
|
||||||
|
'key_length' => 40,
|
||||||
|
'ignore_case' => false,
|
||||||
|
'cost' => null,
|
||||||
|
'memory_cost' => null,
|
||||||
|
'time_cost' => null,
|
||||||
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
|
],
|
||||||
|
'JMS\FooBundle\Entity\User3' => [
|
||||||
|
'algorithm' => 'md5',
|
||||||
|
'hash_algorithm' => 'sha512',
|
||||||
|
'key_length' => 40,
|
||||||
|
'ignore_case' => false,
|
||||||
|
'encode_as_base64' => true,
|
||||||
|
'iterations' => 5000,
|
||||||
|
'cost' => null,
|
||||||
|
'memory_cost' => null,
|
||||||
|
'time_cost' => null,
|
||||||
|
'threads' => null,
|
||||||
|
'migrate_from' => [],
|
||||||
],
|
],
|
||||||
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
'JMS\FooBundle\Entity\User4' => new Reference('security.encoder.foo'),
|
||||||
'JMS\FooBundle\Entity\User5' => [
|
'JMS\FooBundle\Entity\User5' => [
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$this->load('container1.php', $container);
|
||||||
|
|
||||||
|
$container->loadFromExtension('security', [
|
||||||
|
'encoders' => [
|
||||||
|
'JMS\FooBundle\Entity\User7' => [
|
||||||
|
'algorithm' => 'argon2i',
|
||||||
|
'memory_cost' => 256,
|
||||||
|
'time_cost' => 1,
|
||||||
|
'migrate_from' => 'bcrypt',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<container xmlns="http://symfony.com/schema/dic/services"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:sec="http://symfony.com/schema/dic/security"
|
||||||
|
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
|
||||||
|
|
||||||
|
<imports>
|
||||||
|
<import resource="container1.xml"/>
|
||||||
|
</imports>
|
||||||
|
|
||||||
|
<sec:config>
|
||||||
|
<sec:encoder class="JMS\FooBundle\Entity\User7" algorithm="argon2i" memory-cost="256" time-cost="1">
|
||||||
|
<sec:migrate-from>bcrypt</sec:migrate-from>
|
||||||
|
</sec:encoder>
|
||||||
|
</sec:config>
|
||||||
|
|
||||||
|
</container>
|
@ -0,0 +1,10 @@
|
|||||||
|
imports:
|
||||||
|
- { resource: container1.yml }
|
||||||
|
|
||||||
|
security:
|
||||||
|
encoders:
|
||||||
|
JMS\FooBundle\Entity\User7:
|
||||||
|
algorithm: argon2i
|
||||||
|
memory_cost: 256
|
||||||
|
time_cost: 1
|
||||||
|
migrate_from: bcrypt
|
@ -65,9 +65,10 @@ class EncoderFactory implements EncoderFactoryInterface
|
|||||||
*
|
*
|
||||||
* @throws \InvalidArgumentException
|
* @throws \InvalidArgumentException
|
||||||
*/
|
*/
|
||||||
private function createEncoder(array $config): PasswordEncoderInterface
|
private function createEncoder(array $config, bool $isExtra = false): PasswordEncoderInterface
|
||||||
{
|
{
|
||||||
if (isset($config['algorithm'])) {
|
if (isset($config['algorithm'])) {
|
||||||
|
$rawConfig = $config;
|
||||||
$config = $this->getEncoderConfigFromAlgorithm($config);
|
$config = $this->getEncoderConfigFromAlgorithm($config);
|
||||||
}
|
}
|
||||||
if (!isset($config['class'])) {
|
if (!isset($config['class'])) {
|
||||||
@ -79,7 +80,23 @@ class EncoderFactory implements EncoderFactoryInterface
|
|||||||
|
|
||||||
$reflection = new \ReflectionClass($config['class']);
|
$reflection = new \ReflectionClass($config['class']);
|
||||||
|
|
||||||
return $reflection->newInstanceArgs($config['arguments']);
|
$encoder = $reflection->newInstanceArgs($config['arguments']);
|
||||||
|
|
||||||
|
if ($isExtra || !\in_array($config['class'], [NativePasswordEncoder::class, SodiumPasswordEncoder::class], true)) {
|
||||||
|
return $encoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rawConfig ?? null) {
|
||||||
|
$extraEncoders = array_map(function (string $algo) use ($rawConfig): PasswordEncoderInterface {
|
||||||
|
$rawConfig['algorithm'] = $algo;
|
||||||
|
|
||||||
|
return $this->createEncoder($rawConfig);
|
||||||
|
}, ['pbkdf2', $rawConfig['hash_algorithm'] ?? 'sha512']);
|
||||||
|
} else {
|
||||||
|
$extraEncoders = [new Pbkdf2PasswordEncoder(), new MessageDigestPasswordEncoder()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MigratingPasswordEncoder($encoder, ...$extraEncoders);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getEncoderConfigFromAlgorithm(array $config): array
|
private function getEncoderConfigFromAlgorithm(array $config): array
|
||||||
@ -89,7 +106,25 @@ class EncoderFactory implements EncoderFactoryInterface
|
|||||||
// "plaintext" is not listed as any leaked hashes could then be used to authenticate directly
|
// "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) {
|
foreach ([SodiumPasswordEncoder::isSupported() ? 'sodium' : 'native', 'pbkdf2', $config['hash_algorithm']] as $algo) {
|
||||||
$config['algorithm'] = $algo;
|
$config['algorithm'] = $algo;
|
||||||
$encoderChain[] = $this->createEncoder($config);
|
$encoderChain[] = $this->createEncoder($config, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'class' => MigratingPasswordEncoder::class,
|
||||||
|
'arguments' => $encoderChain,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fromEncoders = ($config['migrate_from'] ?? false)) {
|
||||||
|
$encoderChain = [];
|
||||||
|
foreach ($fromEncoders as $name) {
|
||||||
|
if ($encoder = $this->encoders[$name] ?? false) {
|
||||||
|
$encoder = $encoder instanceof PasswordEncoderInterface ? $encoder : $this->createEncoder($encoder, true);
|
||||||
|
} else {
|
||||||
|
$encoder = $this->createEncoder(['algorithm' => $name], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoderChain[] = $encoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
@ -15,6 +15,9 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use Symfony\Component\Security\Core\Encoder\EncoderAwareInterface;
|
use Symfony\Component\Security\Core\Encoder\EncoderAwareInterface;
|
||||||
use Symfony\Component\Security\Core\Encoder\EncoderFactory;
|
use Symfony\Component\Security\Core\Encoder\EncoderFactory;
|
||||||
use Symfony\Component\Security\Core\Encoder\MessageDigestPasswordEncoder;
|
use Symfony\Component\Security\Core\Encoder\MessageDigestPasswordEncoder;
|
||||||
|
use Symfony\Component\Security\Core\Encoder\MigratingPasswordEncoder;
|
||||||
|
use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder;
|
||||||
|
use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder;
|
||||||
use Symfony\Component\Security\Core\User\User;
|
use Symfony\Component\Security\Core\User\User;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
@ -131,6 +134,44 @@ class EncoderFactoryTest extends TestCase
|
|||||||
$expectedEncoder = new MessageDigestPasswordEncoder('sha1');
|
$expectedEncoder = new MessageDigestPasswordEncoder('sha1');
|
||||||
$this->assertEquals($expectedEncoder->encodePassword('foo', ''), $encoder->encodePassword('foo', ''));
|
$this->assertEquals($expectedEncoder->encodePassword('foo', ''), $encoder->encodePassword('foo', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testMigrateFrom()
|
||||||
|
{
|
||||||
|
if (!SodiumPasswordEncoder::isSupported()) {
|
||||||
|
$this->markTestSkipped('Sodium is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
$factory = new EncoderFactory([
|
||||||
|
'digest_encoder' => $digest = new MessageDigestPasswordEncoder('sha256'),
|
||||||
|
'pbdkf2' => $digest = new MessageDigestPasswordEncoder('sha256'),
|
||||||
|
'bcrypt_encoder' => ['algorithm' => 'bcrypt'],
|
||||||
|
SomeUser::class => ['algorithm' => 'sodium', 'migrate_from' => ['bcrypt_encoder', 'digest_encoder']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$encoder = $factory->getEncoder(SomeUser::class);
|
||||||
|
$this->assertInstanceOf(MigratingPasswordEncoder::class, $encoder);
|
||||||
|
|
||||||
|
$this->assertTrue($encoder->isPasswordValid((new SodiumPasswordEncoder())->encodePassword('foo', null), 'foo', null));
|
||||||
|
$this->assertTrue($encoder->isPasswordValid((new NativePasswordEncoder(null, null, null, \PASSWORD_BCRYPT))->encodePassword('foo', null), 'foo', null));
|
||||||
|
$this->assertTrue($encoder->isPasswordValid($digest->encodePassword('foo', null), 'foo', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultMigratingEncoders()
|
||||||
|
{
|
||||||
|
$this->assertInstanceOf(
|
||||||
|
MigratingPasswordEncoder::class,
|
||||||
|
(new EncoderFactory([SomeUser::class => ['class' => NativePasswordEncoder::class, 'arguments' => []]]))->getEncoder(SomeUser::class)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!SodiumPasswordEncoder::isSupported()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertInstanceOf(
|
||||||
|
MigratingPasswordEncoder::class,
|
||||||
|
(new EncoderFactory([SomeUser::class => ['class' => SodiumPasswordEncoder::class, 'arguments' => []]]))->getEncoder(SomeUser::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SomeUser implements UserInterface
|
class SomeUser implements UserInterface
|
||||||
|
Reference in New Issue
Block a user