diff --git a/UPGRADE-5.3.md b/UPGRADE-5.3.md index 22fab6fc0f6f4..d4b8bebe179ad 100644 --- a/UPGRADE-5.3.md +++ b/UPGRADE-5.3.md @@ -69,8 +69,19 @@ PropertyInfo Security -------- + * Deprecate all classes in the `Core\Encoder\` sub-namespace, use the `PasswordHasher` component instead * Deprecated voters that do not return a valid decision when calling the `vote` method +SecurityBundle +-------------- + + * Deprecate `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command, + use `UserPasswordHashCommand` and `user:hash-password` instead + * Deprecate the `security.encoder_factory.generic` service, the `security.encoder_factory` and `Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface` aliases, + use `security.password_hasher_factory` and `Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface` instead + * Deprecate the `security.user_password_encoder.generic` service, the `security.password_encoder` and the `Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface` aliases, + use `security.user_password_hasher`, `security.password_hasher` and `Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface` instead + Serializer ---------- diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index 835222fdd349c..a6ee83505cf9e 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -166,6 +166,7 @@ Routing Security -------- + * Drop all classes in the `Core\Encoder\` sub-namespace, use the `PasswordHasher` component instead * Drop support for `SessionInterface $session` as constructor argument of `SessionTokenStorage`, inject a `\Symfony\Component\HttpFoundation\RequestStack $requestStack` instead * Drop support for `session` provided by the ServiceLocator injected in `UsageTrackingTokenStorage`, provide a `request_stack` service instead * Make `SessionTokenStorage` throw a `SessionNotFoundException` when called outside a request context @@ -179,6 +180,16 @@ Security * Removed the `AbstractRememberMeServices::$providerKey` property in favor of `AbstractRememberMeServices::$firewallName` * `AccessDecisionManager` now throw an exception when a voter does not return a valid decision. +SecurityBundle +-------------- + + * Remove the `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command, + use `UserPasswordHashCommand` and `user:hash-password` instead + * Remove the `security.encoder_factory.generic` service, the `security.encoder_factory` and `Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface` aliases, + use `security.password_hasher_factory` and `Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface` instead + * Remove the `security.user_password_encoder.generic` service, the `security.password_encoder` and the `Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface` aliases, + use `security.user_password_hasher`, `security.password_hasher` and `Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface` instead + Serializer ---------- diff --git a/composer.json b/composer.json index c39d3cb115961..41e1911b6eca3 100644 --- a/composer.json +++ b/composer.json @@ -86,6 +86,7 @@ "symfony/monolog-bridge": "self.version", "symfony/notifier": "self.version", "symfony/options-resolver": "self.version", + "symfony/password-hasher": "self.version", "symfony/process": "self.version", "symfony/property-access": "self.version", "symfony/property-info": "self.version", diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index c5256e2ba3774..0f66c585ed0f2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -51,7 +51,7 @@ "symfony/messenger": "^5.2", "symfony/mime": "^4.4|^5.0", "symfony/process": "^4.4|^5.0", - "symfony/security-bundle": "^5.2", + "symfony/security-bundle": "^5.3", "symfony/serializer": "^5.2", "symfony/stopwatch": "^4.4|^5.0", "symfony/string": "^5.0", diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 4e1ccb8d2b9fb..d50c23959b74c 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -1,6 +1,16 @@ CHANGELOG ========= +5.3 +--- + + * Deprecate `UserPasswordEncoderCommand` class and the corresponding `user:encode-password` command, + use `UserPasswordHashCommand` and `user:hash-password` instead + * Deprecate the `security.encoder_factory.generic` service, the `security.encoder_factory` and `Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface` aliases, + use `security.password_hasher_factory` and `Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface` instead + * Deprecate the `security.user_password_encoder.generic` service, the `security.password_encoder` and the `Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface` aliases, + use `security.user_password_hasher`, `security.password_hasher` and `Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface` instead + 5.2.0 ----- diff --git a/src/Symfony/Bundle/SecurityBundle/Command/UserPasswordEncoderCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/UserPasswordEncoderCommand.php index 8352bc41a8b4c..4b573a061d545 100644 --- a/src/Symfony/Bundle/SecurityBundle/Command/UserPasswordEncoderCommand.php +++ b/src/Symfony/Bundle/SecurityBundle/Command/UserPasswordEncoderCommand.php @@ -21,6 +21,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\PasswordHasher\Command\UserPasswordHashCommand; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; use Symfony\Component\Security\Core\Encoder\SelfSaltingEncoderInterface; @@ -30,6 +31,8 @@ * @author Sarah Khalil * * @final + * + * @deprecated since Symfony 5.3, use {@link UserPasswordHashCommand} instead */ class UserPasswordEncoderCommand extends Command { @@ -107,6 +110,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $errorIo = $output instanceof ConsoleOutputInterface ? new SymfonyStyle($input, $output->getErrorOutput()) : $io; + $errorIo->caution('The use of the "security:encode-password" command is deprecated since version 5.3 and will be removed in 6.0. Use "security:hash-password" instead.'); + $input->isInteractive() ? $errorIo->title('Symfony Password Encoder Utility') : $errorIo->newLine(); $password = $input->getArgument('password'); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 3d3000d8cd92d..6befc9319bfef 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -65,6 +65,23 @@ public function getConfigTreeBuilder() return $v; }) ->end() + ->beforeNormalization() + ->ifTrue(function ($v) { + if ($v['encoders'] ?? false) { + trigger_deprecation('symfony/security-bundle', '5.3', 'The child node "encoders" at path "security" is deprecated, use "password_hashers" instead.'); + + return true; + } + + return $v['password_hashers'] ?? false; + }) + ->then(function ($v) { + $v['password_hashers'] = array_merge($v['password_hashers'] ?? [], $v['encoders'] ?? []); + $v['encoders'] = $v['password_hashers']; + + return $v; + }) + ->end() ->children() ->scalarNode('access_denied_url')->defaultNull()->example('/foo/error403')->end() ->enumNode('session_fixation_strategy') @@ -94,6 +111,7 @@ public function getConfigTreeBuilder() ; $this->addEncodersSection($rootNode); + $this->addPasswordHashersSection($rootNode); $this->addProvidersSection($rootNode); $this->addFirewallsSection($rootNode, $this->factories); $this->addAccessControlSection($rootNode); @@ -401,6 +419,57 @@ private function addEncodersSection(ArrayNodeDefinition $rootNode) ; } + private function addPasswordHashersSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->fixXmlConfig('password_hasher') + ->children() + ->arrayNode('password_hashers') + ->example([ + 'App\Entity\User1' => 'auto', + 'App\Entity\User2' => [ + 'algorithm' => 'auto', + 'time_cost' => 8, + 'cost' => 13, + ], + ]) + ->requiresAtLeastOneElement() + ->useAttributeAsKey('class') + ->prototype('array') + ->canBeUnset() + ->performNoDeepMerging() + ->beforeNormalization()->ifString()->then(function ($v) { return ['algorithm' => $v]; })->end() + ->children() + ->scalarNode('algorithm') + ->cannotBeEmpty() + ->validate() + ->ifTrue(function ($v) { return !\is_string($v); }) + ->thenInvalid('You must provide a string value.') + ->end() + ->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('key_length')->defaultValue(40)->end() + ->booleanNode('ignore_case')->defaultFalse()->end() + ->booleanNode('encode_as_base64')->defaultTrue()->end() + ->scalarNode('iterations')->defaultValue(5000)->end() + ->integerNode('cost') + ->min(4) + ->max(31) + ->defaultNull() + ->end() + ->scalarNode('memory_cost')->defaultNull()->end() + ->scalarNode('time_cost')->defaultNull()->end() + ->scalarNode('id')->end() + ->end() + ->end() + ->end() + ->end(); + } + private function getAccessDecisionStrategies() { $strategies = [ diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index f991514ab46a4..368f4acf9f3d1 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -32,6 +32,10 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; @@ -105,6 +109,7 @@ public function load(array $configs, ContainerBuilder $container) $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); $loader->load('security.php'); + $loader->load('password_hasher.php'); $loader->load('security_listeners.php'); $loader->load('security_rememberme.php'); @@ -166,13 +171,22 @@ public function load(array $configs, ContainerBuilder $container) $container->getDefinition('security.authentication.guard_handler') ->replaceArgument(2, $this->statelessFirewallKeys); + // @deprecated since Symfony 5.3 if ($config['encoders']) { $this->createEncoders($config['encoders'], $container); } + if ($config['password_hashers']) { + $this->createHashers($config['password_hashers'], $container); + } + if (class_exists(Application::class)) { $loader->load('console.php'); + + // @deprecated since Symfony 5.3 $container->getDefinition('security.command.user_password_encoder')->replaceArgument(1, array_keys($config['encoders'])); + + $container->getDefinition('security.command.user_password_hash')->replaceArgument(1, array_keys($config['password_hashers'])); } $container->registerForAutoconfiguration(VoterInterface::class) @@ -689,20 +703,20 @@ private function createEncoder(array $config) // Argon2i encoder if ('argon2i' === $config['algorithm']) { - if (SodiumPasswordEncoder::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + if (SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { $config['algorithm'] = 'sodium'; } elseif (\defined('PASSWORD_ARGON2I')) { $config['algorithm'] = 'native'; $config['native_algorithm'] = \PASSWORD_ARGON2I; } else { - throw new InvalidConfigurationException(sprintf('Algorithm "argon2i" is not available. Either use "%s" or upgrade to PHP 7.2+ instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? 'argon2id", "auto' : 'auto')); + throw new InvalidConfigurationException(sprintf('Algorithm "argon2i" is not available. Use "%s" instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? 'argon2id", "auto' : 'auto')); } return $this->createEncoder($config); } if ('argon2id' === $config['algorithm']) { - if (($hasSodium = SodiumPasswordEncoder::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + if (($hasSodium = SodiumPasswordHasher::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { $config['algorithm'] = 'sodium'; } elseif (\defined('PASSWORD_ARGON2ID')) { $config['algorithm'] = 'native'; @@ -717,6 +731,117 @@ private function createEncoder(array $config) if ('native' === $config['algorithm']) { return [ 'class' => NativePasswordEncoder::class, + 'arguments' => [ + $config['time_cost'], + (($config['memory_cost'] ?? 0) << 10) ?: null, + $config['cost'], + ] + (isset($config['native_algorithm']) ? [3 => $config['native_algorithm']] : []), + ]; + } + + if ('sodium' === $config['algorithm']) { + if (!SodiumPasswordHasher::isSupported()) { + throw new InvalidConfigurationException('Libsodium is not available. Install the sodium extension or use "auto" instead.'); + } + + return [ + 'class' => SodiumPasswordEncoder::class, + 'arguments' => [ + $config['time_cost'], + (($config['memory_cost'] ?? 0) << 10) ?: null, + ], + ]; + } + + // run-time configured encoder + return $config; + } + + private function createHashers(array $hashers, ContainerBuilder $container) + { + $hasherMap = []; + foreach ($hashers as $class => $hasher) { + $hasherMap[$class] = $this->createHasher($hasher); + } + + $container + ->getDefinition('security.password_hasher_factory') + ->setArguments([$hasherMap]) + ; + } + + private function createHasher(array $config) + { + // a custom hasher service + if (isset($config['id'])) { + return new Reference($config['id']); + } + + if ($config['migrate_from'] ?? false) { + return $config; + } + + // plaintext hasher + if ('plaintext' === $config['algorithm']) { + $arguments = [$config['ignore_case']]; + + return [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => $arguments, + ]; + } + + // pbkdf2 hasher + if ('pbkdf2' === $config['algorithm']) { + return [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => [ + $config['hash_algorithm'], + $config['encode_as_base64'], + $config['iterations'], + $config['key_length'], + ], + ]; + } + + // bcrypt hasher + if ('bcrypt' === $config['algorithm']) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_BCRYPT; + + return $this->createHasher($config); + } + + // Argon2i hasher + if ('argon2i' === $config['algorithm']) { + if (SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + $config['algorithm'] = 'sodium'; + } elseif (\defined('PASSWORD_ARGON2I')) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_ARGON2I; + } else { + throw new InvalidConfigurationException(sprintf('Algorithm "argon2i" is not available. Either use "%s" or upgrade to PHP 7.2+ instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? 'argon2id", "auto' : 'auto')); + } + + return $this->createHasher($config); + } + + if ('argon2id' === $config['algorithm']) { + if (($hasSodium = SodiumPasswordHasher::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + $config['algorithm'] = 'sodium'; + } elseif (\defined('PASSWORD_ARGON2ID')) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_ARGON2ID; + } else { + throw new InvalidConfigurationException(sprintf('Algorithm "argon2id" is not available. Either use "%s", upgrade to PHP 7.3+ or use libsodium 1.0.15+ instead.', \defined('PASSWORD_ARGON2I') || $hasSodium ? 'argon2i", "auto' : 'auto')); + } + + return $this->createHasher($config); + } + + if ('native' === $config['algorithm']) { + return [ + 'class' => NativePasswordHasher::class, 'arguments' => [ $config['time_cost'], (($config['memory_cost'] ?? 0) << 10) ?: null, @@ -726,12 +851,12 @@ private function createEncoder(array $config) } if ('sodium' === $config['algorithm']) { - if (!SodiumPasswordEncoder::isSupported()) { + if (!SodiumPasswordHasher::isSupported()) { throw new InvalidConfigurationException('Libsodium is not available. Install the sodium extension or use "auto" instead.'); } return [ - 'class' => SodiumPasswordEncoder::class, + 'class' => SodiumPasswordHasher::class, 'arguments' => [ $config['time_cost'], (($config['memory_cost'] ?? 0) << 10) ?: null, @@ -739,7 +864,7 @@ private function createEncoder(array $config) ]; } - // run-time configured encoder + // run-time configured hasher return $config; } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php index 61bc1f553e582..5bfe8a2c3a2cf 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Bundle\SecurityBundle\Command\UserPasswordEncoderCommand; +use Symfony\Component\PasswordHasher\Command\UserPasswordHashCommand; return static function (ContainerConfigurator $container) { $container->services() @@ -20,6 +21,16 @@ service('security.encoder_factory'), abstract_arg('encoders user classes'), ]) - ->tag('console.command') + ->tag('console.command', ['command' => 'security:encode-password']) + ->deprecate('symfony/security-bundle', '5.3', 'The "%service_id%" service is deprecated, use "security.command.user_password_hash" instead.') + ; + + $container->services() + ->set('security.command.user_password_hash', UserPasswordHashCommand::class) + ->args([ + service('security.password_hasher_factory'), + abstract_arg('list of user classes'), + ]) + ->tag('console.command') ; }; diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php index f8b79cb3569d2..60677a94dec73 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php @@ -34,7 +34,7 @@ abstract_arg('User Provider'), abstract_arg('Provider-shared Key'), abstract_arg('User Checker'), - service('security.password_encoder'), + service('security.password_hasher'), ]) ->set('security.authentication.listener.guard', GuardAuthenticationListener::class) diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php new file mode 100644 index 0000000000000..50e1be8d981cd --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('security.password_hasher_factory', PasswordHasherFactory::class) + ->args([[]]) + ->alias(PasswordHasherFactoryInterface::class, 'security.password_hasher_factory') + + ->set('security.user_password_hasher', UserPasswordHasher::class) + ->args([service('security.password_hasher_factory')]) + ->alias('security.password_hasher', 'security.user_password_hasher') + ->alias(UserPasswordHasherInterface::class, 'security.password_hasher') + ; +}; diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd index 8ff0d5e46da0d..01e1e9eba0141 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd @@ -11,6 +11,8 @@ + + @@ -31,6 +33,12 @@ + + + + + + @@ -84,6 +92,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index ecc9fc14fc08a..e4d3c49f881fe 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -18,6 +18,8 @@ use Symfony\Bundle\SecurityBundle\Security\FirewallMap; use Symfony\Bundle\SecurityBundle\Security\LazyFirewallContext; use Symfony\Component\Ldap\Security\LdapUserProvider; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; @@ -109,13 +111,20 @@ ->args([ [], ]) + ->deprecate('symfony/security-bundle', '5.3', 'The "%service_id%" service is deprecated, use "security.password_hasher_factory" instead.') ->alias('security.encoder_factory', 'security.encoder_factory.generic') + ->deprecate('symfony/security-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "security.password_hasher_factory" instead.') ->alias(EncoderFactoryInterface::class, 'security.encoder_factory') + ->deprecate('symfony/security-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "'.PasswordHasherFactoryInterface::class.'" instead.') ->set('security.user_password_encoder.generic', UserPasswordEncoder::class) ->args([service('security.encoder_factory')]) - ->alias('security.password_encoder', 'security.user_password_encoder.generic')->public() + ->deprecate('symfony/security-bundle', '5.3', 'The "%service_id%" service is deprecated, use "security.user_password_hasher" instead.') + ->alias('security.password_encoder', 'security.user_password_encoder.generic') + ->public() + ->deprecate('symfony/security-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "security.password_hasher"" instead.') ->alias(UserPasswordEncoderInterface::class, 'security.password_encoder') + ->deprecate('symfony/security-bundle', '5.3', 'The "%alias_id%" service is deprecated, use "'.UserPasswordHasherInterface::class.'" instead.') ->set('security.user_checker', UserChecker::class) @@ -260,7 +269,7 @@ ->set('security.validator.user_password', UserPasswordValidator::class) ->args([ service('security.token_storage'), - service('security.encoder_factory'), + service('security.password_hasher_factory'), ]) ->tag('validator.constraint_validator', ['alias' => 'security.validator.user_password']) diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php index 3d0c6ddcb4f9e..1bd7723634f38 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php @@ -72,7 +72,7 @@ // Listeners ->set('security.listener.check_authenticator_credentials', CheckCredentialsListener::class) ->args([ - service('security.encoder_factory'), + service('security.password_hasher_factory'), ]) ->tag('kernel.event_subscriber') @@ -90,7 +90,7 @@ ->set('security.listener.password_migrating', PasswordMigratingListener::class) ->args([ - service('security.encoder_factory'), + service('security.password_hasher_factory'), ]) ->tag('kernel.event_subscriber') diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php index 7683ea2484031..aa6a522de1890 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.php @@ -221,7 +221,7 @@ abstract_arg('User Provider'), abstract_arg('User Checker'), abstract_arg('Provider-shared Key'), - service('security.encoder_factory'), + service('security.password_hasher_factory'), param('security.authentication.hide_user_not_found'), ]) diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php index 072e33aca6f4d..7068821286339 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php @@ -18,6 +18,10 @@ use Symfony\Component\DependencyInjection\Argument\IteratorArgument; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; @@ -275,9 +279,12 @@ public function testMerge() ], $container->getParameter('security.role_hierarchy.roles')); } + /** + * @group legacy + */ public function testEncoders() { - $container = $this->getContainer('container1'); + $container = $this->getContainer('legacy_encoders'); $this->assertEquals([[ 'JMS\FooBundle\Entity\User1' => [ @@ -332,6 +339,9 @@ public function testEncoders() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testEncodersWithLibsodium() { if (!SodiumPasswordEncoder::isSupported()) { @@ -385,6 +395,9 @@ public function testEncodersWithLibsodium() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testEncodersWithArgon2i() { if (!($sodium = SodiumPasswordEncoder::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { @@ -438,6 +451,9 @@ public function testEncodersWithArgon2i() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testMigratingEncoder() { if (!($sodium = SodiumPasswordEncoder::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { @@ -499,6 +515,9 @@ public function testMigratingEncoder() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testEncodersWithBCrypt() { $container = $this->getContainer('bcrypt_encoder'); @@ -548,6 +567,279 @@ public function testEncodersWithBCrypt() ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + public function testHashers() + { + $container = $this->getContainer('container1'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + '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, + '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, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'auto', + 'hash_algorithm' => 'sha512', + 'key_length' => 40, + 'ignore_case' => false, + 'encode_as_base64' => true, + 'iterations' => 5000, + 'cost' => null, + 'memory_cost' => null, + 'time_cost' => null, + 'migrate_from' => [], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + + public function testHashersWithLibsodium() + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Libsodium is not available.'); + } + + $container = $this->getContainer('sodium_hasher'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + '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, + '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, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'class' => SodiumPasswordHasher::class, + 'arguments' => [8, 128 * 1024 * 1024], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + + public function testHashersWithArgon2i() + { + if (!($sodium = SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { + $this->markTestSkipped('Argon2i algorithm is not supported.'); + } + + $container = $this->getContainer('argon2i_hasher'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + '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, + '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, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'class' => $sodium ? SodiumPasswordHasher::class : NativePasswordHasher::class, + 'arguments' => $sodium ? [256, 1] : [1, 262144, null, \PASSWORD_ARGON2I], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + + public function testMigratingHasher() + { + if (!($sodium = SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { + $this->markTestSkipped('Argon2i algorithm is not supported.'); + } + + $container = $this->getContainer('migrating_hasher'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + '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, + '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, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + '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, + 'migrate_from' => ['bcrypt'], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + + public function testHashersWithBCrypt() + { + $container = $this->getContainer('bcrypt_hasher'); + + $this->assertEquals([[ + 'JMS\FooBundle\Entity\User1' => [ + 'class' => PlaintextPasswordHasher::class, + '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, + '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, + 'migrate_from' => [], + ], + 'JMS\FooBundle\Entity\User4' => new Reference('security.hasher.foo'), + 'JMS\FooBundle\Entity\User5' => [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => ['sha1', false, 5, 30], + ], + 'JMS\FooBundle\Entity\User6' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [8, 102400, 15], + ], + 'JMS\FooBundle\Entity\User7' => [ + 'class' => NativePasswordHasher::class, + 'arguments' => [null, null, 15, \PASSWORD_BCRYPT], + ], + ]], $container->getDefinition('security.password_hasher_factory')->getArguments()); + } + public function testRememberMeThrowExceptionsDefault() { $container = $this->getContainer('container1'); @@ -577,9 +869,9 @@ public function testUserCheckerConfigWithNoCheckers() $this->assertEquals('security.user_checker', $this->getContainer('container1')->getAlias('security.user_checker.secure')); } - public function testUserPasswordEncoderCommandIsRegistered() + public function testUserPasswordHasherCommandIsRegistered() { - $this->assertTrue($this->getContainer('remember_me_options')->has('security.command.user_password_encoder')); + $this->assertTrue($this->getContainer('remember_me_options')->has('security.command.user_password_hash')); } public function testDefaultAccessDecisionManagerStrategyIsAffirmative() diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php index ddac043692cf1..ba1e1328b069d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php @@ -1,6 +1,6 @@ load('container1.php'); +$this->load('legacy_encoders.php'); $container->loadFromExtension('security', [ 'encoders' => [ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_hasher.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_hasher.php new file mode 100644 index 0000000000000..341f772e87523 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_hasher.php @@ -0,0 +1,13 @@ +load('container1.php'); + +$container->loadFromExtension('security', [ + 'password_hashers' => [ + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'argon2i', + 'memory_cost' => 256, + 'time_cost' => 1, + ], + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_encoder.php index d4511aeb554c7..0a0a69b6dec0d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_encoder.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_encoder.php @@ -1,6 +1,6 @@ load('container1.php'); +$this->load('legacy_encoders.php'); $container->loadFromExtension('security', [ 'encoders' => [ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_hasher.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_hasher.php new file mode 100644 index 0000000000000..a416b3440d426 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_hasher.php @@ -0,0 +1,12 @@ +load('container1.php'); + +$container->loadFromExtension('security', [ + 'password_hashers' => [ + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'bcrypt', + 'cost' => 15, + ], + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php index 3c9e6104eecc3..f551131f00639 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/container1.php @@ -1,7 +1,7 @@ loadFromExtension('security', [ - 'encoders' => [ + 'password_hashers' => [ 'JMS\FooBundle\Entity\User1' => 'plaintext', 'JMS\FooBundle\Entity\User2' => [ 'algorithm' => 'sha1', @@ -12,7 +12,7 @@ 'algorithm' => 'md5', ], 'JMS\FooBundle\Entity\User4' => [ - 'id' => 'security.encoder.foo', + 'id' => 'security.hasher.foo', ], 'JMS\FooBundle\Entity\User5' => [ 'algorithm' => 'pbkdf2', diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/legacy_encoders.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/legacy_encoders.php new file mode 100644 index 0000000000000..3c9e6104eecc3 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/legacy_encoders.php @@ -0,0 +1,108 @@ +loadFromExtension('security', [ + 'encoders' => [ + 'JMS\FooBundle\Entity\User1' => 'plaintext', + 'JMS\FooBundle\Entity\User2' => [ + 'algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + ], + 'JMS\FooBundle\Entity\User3' => [ + 'algorithm' => 'md5', + ], + 'JMS\FooBundle\Entity\User4' => [ + 'id' => 'security.encoder.foo', + ], + 'JMS\FooBundle\Entity\User5' => [ + 'algorithm' => 'pbkdf2', + 'hash_algorithm' => 'sha1', + 'encode_as_base64' => false, + 'iterations' => 5, + 'key_length' => 30, + ], + 'JMS\FooBundle\Entity\User6' => [ + 'algorithm' => 'native', + 'time_cost' => 8, + 'memory_cost' => 100, + 'cost' => 15, + ], + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'auto', + ], + ], + 'providers' => [ + 'default' => [ + 'memory' => [ + 'users' => [ + 'foo' => ['password' => 'foo', 'roles' => 'ROLE_USER'], + ], + ], + ], + 'digest' => [ + 'memory' => [ + 'users' => [ + 'foo' => ['password' => 'foo', 'roles' => 'ROLE_USER, ROLE_ADMIN'], + ], + ], + ], + 'basic' => [ + 'memory' => [ + 'users' => [ + 'foo' => ['password' => '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', 'roles' => 'ROLE_SUPER_ADMIN'], + 'bar' => ['password' => '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33', 'roles' => ['ROLE_USER', 'ROLE_ADMIN']], + ], + ], + ], + 'service' => [ + 'id' => 'user.manager', + ], + 'chain' => [ + 'chain' => [ + 'providers' => ['service', 'basic'], + ], + ], + ], + + 'firewalls' => [ + 'simple' => ['provider' => 'default', 'pattern' => '/login', 'security' => false], + 'secure' => ['stateless' => true, + 'provider' => 'default', + 'http_basic' => true, + 'form_login' => true, + 'anonymous' => true, + 'switch_user' => true, + 'x509' => true, + 'remote_user' => true, + 'logout' => true, + 'remember_me' => ['secret' => 'TheSecret'], + 'user_checker' => null, + ], + 'host' => [ + 'provider' => 'default', + 'pattern' => '/test', + 'host' => 'foo\\.example\\.org', + 'methods' => ['GET', 'POST'], + 'anonymous' => true, + 'http_basic' => true, + ], + 'with_user_checker' => [ + 'provider' => 'default', + 'user_checker' => 'app.user_checker', + 'anonymous' => true, + 'http_basic' => true, + ], + ], + + 'access_control' => [ + ['path' => '/blog/524', 'role' => 'ROLE_USER', 'requires_channel' => 'https', 'methods' => ['get', 'POST'], 'port' => 8000], + ['path' => '/blog/.*', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY'], + ['path' => '/blog/524', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY', 'allow_if' => "token.getUsername() matches '/^admin/'"], + ], + + 'role_hierarchy' => [ + 'ROLE_ADMIN' => 'ROLE_USER', + 'ROLE_SUPER_ADMIN' => ['ROLE_USER', 'ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'], + 'ROLE_REMOTE' => 'ROLE_USER,ROLE_ADMIN', + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_encoder.php index c7ad9f02ab4f5..04a800a218c59 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_encoder.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_encoder.php @@ -1,6 +1,6 @@ load('container1.php'); +$this->load('legacy_encoders.php'); $container->loadFromExtension('security', [ 'encoders' => [ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_hasher.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_hasher.php new file mode 100644 index 0000000000000..342ea64805eff --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_hasher.php @@ -0,0 +1,14 @@ +load('container1.php'); + +$container->loadFromExtension('security', [ + 'password_hashers' => [ + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'argon2i', + 'memory_cost' => 256, + 'time_cost' => 1, + 'migrate_from' => 'bcrypt', + ], + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_encoder.php index ec0851bdfaa34..3239ed027422b 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_encoder.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_encoder.php @@ -1,6 +1,6 @@ load('container1.php'); +$this->load('legacy_encoders.php'); $container->loadFromExtension('security', [ 'encoders' => [ diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_hasher.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_hasher.php new file mode 100644 index 0000000000000..3ec569ae9a6e2 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_hasher.php @@ -0,0 +1,13 @@ +load('container1.php'); + +$container->loadFromExtension('security', [ + 'password_hashers' => [ + 'JMS\FooBundle\Entity\User7' => [ + 'algorithm' => 'sodium', + 'time_cost' => 8, + 'memory_cost' => 128 * 1024, + ], + ], +]); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml index a4346f824ed14..d18ecd939cbb3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml @@ -9,7 +9,7 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_hasher.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_hasher.xml new file mode 100644 index 0000000000000..3dc2c685be321 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_hasher.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_encoder.xml index d81f3aa73af26..2ac6f38dd476c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_encoder.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_encoder.xml @@ -9,7 +9,7 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_hasher.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_hasher.xml new file mode 100644 index 0000000000000..d4c5d3ded1a11 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_hasher.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml index 84d68cc4fd59b..097a726db58d2 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/container1.xml @@ -9,19 +9,19 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + - + - + - + - + - + - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/legacy_encoders.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/legacy_encoders.xml new file mode 100644 index 0000000000000..84d68cc4fd59b --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/legacy_encoders.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + app.user_checker + + + ROLE_USER + ROLE_USER,ROLE_ADMIN,ROLE_ALLOWED_TO_SWITCH + ROLE_USER,ROLE_ADMIN + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_encoder.xml index db0ca61b60017..a4bd11688e288 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_encoder.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_encoder.xml @@ -9,7 +9,7 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_hasher.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_hasher.xml new file mode 100644 index 0000000000000..a4a9d2010dd71 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_hasher.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + bcrypt + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_encoder.xml index 09e6cacef323f..80ccadf4511cb 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_encoder.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_encoder.xml @@ -9,7 +9,7 @@ https://symfony.com/schema/dic/security/security-1.0.xsd"> - + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_hasher.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_hasher.xml new file mode 100644 index 0000000000000..fd5cacef7b8a4 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_hasher.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml index cadf8eb1e98d2..f4571e678db08 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml @@ -1,5 +1,5 @@ imports: - - { resource: container1.yml } + - { resource: legacy_encoders.yml } security: encoders: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_hasher.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_hasher.yml new file mode 100644 index 0000000000000..1079d6e5f8efc --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_hasher.yml @@ -0,0 +1,9 @@ +imports: + - { resource: container1.yml } + +security: + password_hashers: + JMS\FooBundle\Entity\User7: + algorithm: argon2i + memory_cost: 256 + time_cost: 1 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_encoder.yml index 3f1a526215204..a5bd7d9b3bbce 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_encoder.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_encoder.yml @@ -1,5 +1,5 @@ imports: - - { resource: container1.yml } + - { resource: legacy_encoders.yml } security: encoders: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_hasher.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_hasher.yml new file mode 100644 index 0000000000000..8e8397486d68e --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_hasher.yml @@ -0,0 +1,8 @@ +imports: + - { resource: container1.yml } + +security: + password_hashers: + JMS\FooBundle\Entity\User7: + algorithm: bcrypt + cost: 15 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml index 03b9aaf6ef5b9..0ac2c94b0680b 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/container1.yml @@ -1,5 +1,5 @@ security: - encoders: + password_hashers: JMS\FooBundle\Entity\User1: plaintext JMS\FooBundle\Entity\User2: algorithm: sha1 @@ -8,7 +8,7 @@ security: JMS\FooBundle\Entity\User3: algorithm: md5 JMS\FooBundle\Entity\User4: - id: security.encoder.foo + id: security.hasher.foo JMS\FooBundle\Entity\User5: algorithm: pbkdf2 hash_algorithm: sha1 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/legacy_encoders.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/legacy_encoders.yml new file mode 100644 index 0000000000000..03b9aaf6ef5b9 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/legacy_encoders.yml @@ -0,0 +1,87 @@ +security: + encoders: + JMS\FooBundle\Entity\User1: plaintext + JMS\FooBundle\Entity\User2: + algorithm: sha1 + encode_as_base64: false + iterations: 5 + JMS\FooBundle\Entity\User3: + algorithm: md5 + JMS\FooBundle\Entity\User4: + id: security.encoder.foo + JMS\FooBundle\Entity\User5: + algorithm: pbkdf2 + hash_algorithm: sha1 + encode_as_base64: false + iterations: 5 + key_length: 30 + JMS\FooBundle\Entity\User6: + algorithm: native + time_cost: 8 + memory_cost: 100 + cost: 15 + JMS\FooBundle\Entity\User7: + algorithm: auto + + providers: + default: + memory: + users: + foo: { password: foo, roles: ROLE_USER } + digest: + memory: + users: + foo: { password: foo, roles: 'ROLE_USER, ROLE_ADMIN' } + basic: + memory: + users: + foo: { password: 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33, roles: ROLE_SUPER_ADMIN } + bar: { password: 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33, roles: [ROLE_USER, ROLE_ADMIN] } + service: + id: user.manager + chain: + chain: + providers: [service, basic] + + + firewalls: + simple: { pattern: /login, security: false } + secure: + provider: default + stateless: true + http_basic: true + form_login: true + anonymous: true + switch_user: + x509: true + remote_user: true + logout: true + remember_me: + secret: TheSecret + user_checker: ~ + + host: + provider: default + pattern: /test + host: foo\.example\.org + methods: [GET,POST] + anonymous: true + http_basic: true + + with_user_checker: + provider: default + anonymous: ~ + http_basic: ~ + user_checker: app.user_checker + + role_hierarchy: + ROLE_ADMIN: ROLE_USER + ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH] + ROLE_REMOTE: ROLE_USER,ROLE_ADMIN + + access_control: + - { path: /blog/524, role: ROLE_USER, requires_channel: https, methods: [get, POST], port: 8000} + - + path: /blog/.* + role: IS_AUTHENTICATED_ANONYMOUSLY + - { path: /blog/524, role: IS_AUTHENTICATED_ANONYMOUSLY, allow_if: "token.getUsername() matches '/^admin/'" } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_encoder.yml index 9eda61c18866f..87943cac128ff 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_encoder.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_encoder.yml @@ -1,5 +1,5 @@ imports: - - { resource: container1.yml } + - { resource: legacy_encoders.yml } security: encoders: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_hasher.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_hasher.yml new file mode 100644 index 0000000000000..8657b1ee744ad --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_hasher.yml @@ -0,0 +1,10 @@ +imports: + - { resource: container1.yml } + +security: + password_hashers: + JMS\FooBundle\Entity\User7: + algorithm: argon2i + memory_cost: 256 + time_cost: 1 + migrate_from: bcrypt diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_encoder.yml index 2d70ef0d9b42a..70b4455ce2ebe 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_encoder.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_encoder.yml @@ -1,5 +1,5 @@ imports: - - { resource: container1.yml } + - { resource: legacy_encoders.yml } security: encoders: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_hasher.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_hasher.yml new file mode 100644 index 0000000000000..955a0b2a2059c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_hasher.yml @@ -0,0 +1,9 @@ +imports: + - { resource: container1.yml } + +security: + password_hashers: + JMS\FooBundle\Entity\User7: + algorithm: sodium + time_cost: 8 + memory_cost: 131072 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php index 5846f386b7fca..cab763489393c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php @@ -24,6 +24,7 @@ * Tests UserPasswordEncoderCommand. * * @author Sarah Khalil + * @group legacy */ class UserPasswordEncoderCommandTest extends AbstractWebTestCase { @@ -40,7 +41,7 @@ public function testEncodePasswordEmptySalt() ], ['decorated' => false]); $expected = str_replace("\n", \PHP_EOL, file_get_contents(__DIR__.'/app/PasswordEncode/emptysalt.txt')); - $this->assertEquals($expected, $this->passwordEncoderCommandTester->getDisplay()); + $this->assertStringContainsString($expected, $this->passwordEncoderCommandTester->getDisplay()); } public function testEncodeNoPasswordNoInteraction() diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AbstractTokenCompareRoles/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AbstractTokenCompareRoles/config.yml index 5c86da6252789..2fc91cbcbfd72 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AbstractTokenCompareRoles/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AbstractTokenCompareRoles/config.yml @@ -9,7 +9,7 @@ services: security: - encoders: + password_hashers: \Symfony\Component\Security\Core\User\UserInterface: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/security.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/security.yml index a364148198d31..cb14f50bc2715 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/security.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Authenticator/security.yml @@ -1,7 +1,7 @@ security: enable_authenticator_manager: true - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml index 274ef33204130..65419d2d460a3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/ClearRememberMe/config.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/base_config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/base_config.yml index 6b82dea8de8ec..201e0b8fd1d4f 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/base_config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/CsrfFormLogin/base_config.yml @@ -15,7 +15,7 @@ services: - { name: container.service_subscriber } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml index 25ef98650e419..bcb5d374b002e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml @@ -28,5 +28,5 @@ security: memory: users: john: { password: doe, roles: [ROLE_SECURE] } - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Guarded/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Guarded/config.yml index 101d0c5b1b52c..75fa554462bad 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Guarded/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Guarded/config.yml @@ -14,7 +14,7 @@ services: tags: [controller.service_arguments] security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml index 055fcee19bd94..5bb3de09a9850 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/config.yml @@ -5,7 +5,7 @@ framework: serializer: ~ security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/custom_handlers.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/custom_handlers.yml index c5076cce6fc27..a725338eceec5 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/custom_handlers.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLogin/custom_handlers.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_access.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_access.yml index f49d2f292b770..433e059fe3aa1 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_access.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_access.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_cookie_clearing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_cookie_clearing.yml index f62cc616557a5..a97b1a3a9a9a6 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_cookie_clearing.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/Logout/config_cookie_clearing.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml index 9d92ac82c3c63..21933f99d74c0 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/framework.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml index 7f334ffcaee2f..298574d2b1086 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/RememberMeLogout/config.yml @@ -7,7 +7,7 @@ framework: cookie_samesite: lax security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml index b35ad3f4c91d2..4f3affbf24ea3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml @@ -6,7 +6,7 @@ parameters: env(APP_IPS): '127.0.0.1, ::1' security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/invalid_ip_access_control.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/invalid_ip_access_control.yml index c9fe56e56c739..8254631e51228 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/invalid_ip_access_control.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/invalid_ip_access_control.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/default.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_form_failure_handler.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_form_failure_handler.yml index ced854a6819c9..1a6df70790cde 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_form_failure_handler.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_form_failure_handler.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/default.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_routes.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_routes.yml index b07be914d45f2..5daa020a6a1cc 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_routes.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/localized_routes.yml @@ -2,7 +2,7 @@ imports: - { resource: ./../config/default.yml } security: - encoders: + password_hashers: Symfony\Component\Security\Core\User\User: plaintext providers: diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 3d9adf39396ba..f2a710e1cee7c 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -23,11 +23,12 @@ "symfony/deprecation-contracts": "^2.1", "symfony/event-dispatcher": "^5.1", "symfony/http-kernel": "^5.0", + "symfony/password-hasher": "^5.3", "symfony/polyfill-php80": "^1.15", "symfony/security-core": "^5.3", "symfony/security-csrf": "^4.4|^5.0", - "symfony/security-guard": "^5.2", - "symfony/security-http": "^5.2" + "symfony/security-guard": "^5.3", + "symfony/security-http": "^5.3" }, "require-dev": { "doctrine/doctrine-bundle": "^2.0", diff --git a/src/Symfony/Component/PasswordHasher/.gitattributes b/src/Symfony/Component/PasswordHasher/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/PasswordHasher/.gitignore b/src/Symfony/Component/PasswordHasher/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/PasswordHasher/CHANGELOG.md b/src/Symfony/Component/PasswordHasher/CHANGELOG.md new file mode 100644 index 0000000000000..22693a3bf9dca --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/CHANGELOG.md @@ -0,0 +1,4 @@ +5.3 +--- + + * Add the component diff --git a/src/Symfony/Component/PasswordHasher/Command/UserPasswordHashCommand.php b/src/Symfony/Component/PasswordHasher/Command/UserPasswordHashCommand.php new file mode 100644 index 0000000000000..e4112c9c4e6cb --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Command/UserPasswordHashCommand.php @@ -0,0 +1,213 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Command; + + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * Hashes a user's password. + * + * @author Sarah Khalil + * @author Robin Chalas + * + * @final + */ +class UserPasswordHashCommand extends Command +{ + protected static $defaultName = 'security:hash-password'; + protected static $defaultDescription = "Hashes a user password"; + + private $hasherFactory; + private $userClasses; + + public function __construct(PasswordHasherFactoryInterface $hasherFactory, array $userClasses = []) + { + $this->hasherFactory = $hasherFactory; + $this->userClasses = $userClasses; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setDescription(self::$defaultDescription) + ->addArgument('password', InputArgument::OPTIONAL, 'The plain password to hash.') + ->addArgument('user-class', InputArgument::OPTIONAL, 'The User entity class path associated with the hasher used to hash the password.') + ->addOption('empty-salt', null, InputOption::VALUE_NONE, 'Do not generate a salt or let the hasher generate one.') + ->setHelp(<<%command.name% command hashs passwords according to your +security configuration. This command is mainly used to generate passwords for +the in_memory user provider type and for changing passwords +in the database while developing the application. + +Suppose that you have the following security configuration in your application: + + +# app/config/security.yml +security: + password_hashers: + Symfony\Component\Security\Core\User\User: plaintext + App\Entity\User: auto + + +If you execute the command non-interactively, the first available configured +user class under the security.password_hashers key is used and a random salt is +generated to hash the password: + + php %command.full_name% --no-interaction [password] + +Pass the full user class path as the second argument to hash passwords for +your own entities: + + php %command.full_name% --no-interaction [password] 'App\Entity\User' + +Executing the command interactively allows you to generate a random salt for +hashing the password: + + php %command.full_name% [password] 'App\Entity\User' + +In case your hasher doesn't require a salt, add the empty-salt option: + + php %command.full_name% --empty-salt [password] 'App\Entity\User' + +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $errorIo = $output instanceof ConsoleOutputInterface ? new SymfonyStyle($input, $output->getErrorOutput()) : $io; + + $input->isInteractive() ? $errorIo->title('Symfony Password Hash Utility') : $errorIo->newLine(); + + $password = $input->getArgument('password'); + $userClass = $this->getUserClass($input, $io); + $emptySalt = $input->getOption('empty-salt'); + + $hasher = $this->hasherFactory->getPasswordHasher($userClass); + $saltlessWithoutEmptySalt = !$emptySalt && !$hasher instanceof LegacyPasswordHasherInterface; + + if ($saltlessWithoutEmptySalt) { + $emptySalt = true; + } + + if (!$password) { + if (!$input->isInteractive()) { + $errorIo->error('The password must not be empty.'); + + return 1; + } + $passwordQuestion = $this->createPasswordQuestion(); + $password = $errorIo->askQuestion($passwordQuestion); + } + + $salt = null; + + if ($input->isInteractive() && !$emptySalt) { + $emptySalt = true; + + $errorIo->note('The command will take care of generating a salt for you. Be aware that some hashers advise to let them generate their own salt. If you\'re using one of those hashers, please answer \'no\' to the question below. '.\PHP_EOL.'Provide the \'empty-salt\' option in order to let the hasher handle the generation itself.'); + + if ($errorIo->confirm('Confirm salt generation ?')) { + $salt = $this->generateSalt(); + $emptySalt = false; + } + } elseif (!$emptySalt) { + $salt = $this->generateSalt(); + } + + $hashedPassword = $hasher->hash($password, $salt); + + $rows = [ + ['Hasher used', \get_class($hasher)], + ['Password hash', $hashedPassword], + ]; + if (!$emptySalt) { + $rows[] = ['Generated salt', $salt]; + } + $io->table(['Key', 'Value'], $rows); + + if (!$emptySalt) { + $errorIo->note(sprintf('Make sure that your salt storage field fits the salt length: %s chars', \strlen($salt))); + } elseif ($saltlessWithoutEmptySalt) { + $errorIo->note('Self-salting hasher used: the hasher generated its own built-in salt.'); + } + + $errorIo->success('Password hashing succeeded'); + + return 0; + } + + /** + * Create the password question to ask the user for the password to be hashed. + */ + private function createPasswordQuestion(): Question + { + $passwordQuestion = new Question('Type in your password to be hashed'); + + return $passwordQuestion->setValidator(function ($value) { + if ('' === trim($value)) { + throw new InvalidArgumentException('The password must not be empty.'); + } + + return $value; + })->setHidden(true)->setMaxAttempts(20); + } + + private function generateSalt(): string + { + return base64_encode(random_bytes(30)); + } + + private function getUserClass(InputInterface $input, SymfonyStyle $io): string + { + if (null !== $userClass = $input->getArgument('user-class')) { + return $userClass; + } + + if (!$this->userClasses) { + throw new RuntimeException('There are no configured password hashers for the "security" extension.'); + } + + if (!$input->isInteractive() || 1 === \count($this->userClasses)) { + return reset($this->userClasses); + } + + $userClasses = $this->userClasses; + natcasesort($userClasses); + $userClasses = array_values($userClasses); + + return $io->choice('For which user class would you like to hash a password?', $userClasses, reset($userClasses)); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Exception/ExceptionInterface.php b/src/Symfony/Component/PasswordHasher/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..2d80d8a78f8ee --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Exception; + +/** + * Interface for exceptions thrown by the password-hasher component. + * + * @author Robin Chalas + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/PasswordHasher/Exception/InvalidPasswordException.php b/src/Symfony/Component/PasswordHasher/Exception/InvalidPasswordException.php new file mode 100644 index 0000000000000..dea9109baeb48 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Exception/InvalidPasswordException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Exception; + +/** + * @author Robin Chalas +*/ +class InvalidPasswordException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(string $message = 'Invalid password.', int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Exception/LogicException.php b/src/Symfony/Component/PasswordHasher/Exception/LogicException.php new file mode 100644 index 0000000000000..a0c425fa6fa57 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Exception; + +/** + * @author Robin Chalas +*/ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/CheckPasswordLengthTrait.php b/src/Symfony/Component/PasswordHasher/Hasher/CheckPasswordLengthTrait.php new file mode 100644 index 0000000000000..2dce065ff8191 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/CheckPasswordLengthTrait.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * @author Robin Chalas + */ +trait CheckPasswordLengthTrait +{ + private function isPasswordTooLong(string $password): bool + { + return PasswordHasherInterface::MAX_PASSWORD_LENGTH < \strlen($password); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/MessageDigestPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/MessageDigestPasswordHasher.php new file mode 100644 index 0000000000000..0dd18b276bdde --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/MessageDigestPasswordHasher.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Exception\LogicException; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * MessageDigestPasswordHasher uses a message digest algorithm. + * + * @author Fabien Potencier + */ +class MessageDigestPasswordHasher implements LegacyPasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $algorithm; + private $encodeHashAsBase64; + private $iterations = 1; + private $hashLength = -1; + + /** + * @param string $algorithm The digest algorithm to use + * @param bool $encodeHashAsBase64 Whether to base64 encode the password hash + * @param int $iterations The number of iterations to use to stretch the password hash + */ + public function __construct(string $algorithm = 'sha512', bool $encodeHashAsBase64 = true, int $iterations = 5000) + { + $this->algorithm = $algorithm; + $this->encodeHashAsBase64 = $encodeHashAsBase64; + + try { + $this->hashLength = \strlen($this->hash('', 'salt')); + } catch (\LogicException $e) { + // ignore algorithm not supported + } + + $this->iterations = $iterations; + } + + public function hash(string $plainPassword, ?string $salt = null): string + { + if ($this->isPasswordTooLong($plainPassword)) { + throw new InvalidPasswordException(); + } + + if (!\in_array($this->algorithm, hash_algos(), true)) { + throw new LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); + } + + $salted = $this->mergePasswordAndSalt($plainPassword, $salt); + $digest = hash($this->algorithm, $salted, true); + + // "stretch" hash + for ($i = 1; $i < $this->iterations; ++$i) { + $digest = hash($this->algorithm, $digest.$salted, true); + } + + return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); + } + + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool + { + if (\strlen($hashedPassword) !== $this->hashLength || false !== strpos($hashedPassword, '$')) { + return false; + } + + return !$this->isPasswordTooLong($plainPassword) && hash_equals($hashedPassword, $this->hash($plainPassword, $salt)); + } + + public function needsRehash(string $hashedPassword): bool + { + return false; + } + + private function mergePasswordAndSalt(string $password, ?string $salt): string + { + if (!$salt) { + return $password; + } + + if (false !== strrpos($salt, '{') || false !== strrpos($salt, '}')) { + throw new \InvalidArgumentException('Cannot use { or } in salt.'); + } + + return $password.'{'.$salt.'}'; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/MigratingPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/MigratingPasswordHasher.php new file mode 100644 index 0000000000000..f48373c6e9693 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/MigratingPasswordHasher.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * Hashes passwords using the best available hasher. + * Verifies them using a chain of hashers. + * + * /!\ Don't put a PlaintextPasswordHasher in the list as that'd mean a leaked hash + * could be used to authenticate successfully without knowing the cleartext password. + * + * @author Nicolas Grekas + */ +final class MigratingPasswordHasher implements PasswordHasherInterface +{ + private $bestHasher; + private $extraHashers; + + public function __construct(PasswordHasherInterface $bestHasher, PasswordHasherInterface ...$extraHashers) + { + $this->bestHasher = $bestHasher; + $this->extraHashers = $extraHashers; + } + + public function hash(string $plainPassword, ?string $salt = null): string + { + return $this->bestHasher->hash($plainPassword, $salt); + } + + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool + { + if ($this->bestHasher->verify($hashedPassword, $plainPassword, $salt)) { + return true; + } + + if (!$this->bestHasher->needsRehash($hashedPassword)) { + return false; + } + + foreach ($this->extraHashers as $hasher) { + if ($hasher->verify($hashedPassword, $plainPassword, $salt)) { + return true; + } + } + + return false; + } + + public function needsRehash(string $hashedPassword): bool + { + return $this->bestHasher->needsRehash($hashedPassword); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/NativePasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/NativePasswordHasher.php new file mode 100644 index 0000000000000..f147868ad8dfc --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/NativePasswordHasher.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * Hashes passwords using password_hash(). + * + * @author Elnur Abdurrakhimov + * @author Terje Bråten + * @author Nicolas Grekas + */ +final class NativePasswordHasher implements PasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $algo = \PASSWORD_BCRYPT; + private $options; + + /** + * @param string|null $algo An algorithm supported by password_hash() or null to use the stronger available algorithm + */ + public function __construct(int $opsLimit = null, int $memLimit = null, int $cost = null, ?string $algo = null) + { + $cost = $cost ?? 13; + $opsLimit = $opsLimit ?? max(4, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); + $memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); + + if (3 > $opsLimit) { + throw new \InvalidArgumentException('$opsLimit must be 3 or greater.'); + } + + if (10 * 1024 > $memLimit) { + throw new \InvalidArgumentException('$memLimit must be 10k or greater.'); + } + + if ($cost < 4 || 31 < $cost) { + throw new \InvalidArgumentException('$cost must be in the range of 4-31.'); + } + + $algos = [1 => \PASSWORD_BCRYPT, '2y' => \PASSWORD_BCRYPT]; + + if (\defined('PASSWORD_ARGON2I')) { + $this->algo = $algos[2] = $algos['argon2i'] = (string) \PASSWORD_ARGON2I; + } + + if (\defined('PASSWORD_ARGON2ID')) { + $this->algo = $algos[3] = $algos['argon2id'] = (string) \PASSWORD_ARGON2ID; + } + + if (null !== $algo) { + $this->algo = $algos[$algo] ?? $algo; + } + + $this->options = [ + 'cost' => $cost, + 'time_cost' => $opsLimit, + 'memory_cost' => $memLimit >> 10, + 'threads' => 1, + ]; + } + + public function hash(string $plainPassword): string + { + if ($this->isPasswordTooLong($plainPassword) || ((string) \PASSWORD_BCRYPT === $this->algo && 72 < \strlen($plainPassword))) { + throw new InvalidPasswordException(); + } + + return password_hash($plainPassword, $this->algo, $this->options); + } + + public function verify(string $hashedPassword, string $plainPassword): bool + { + if ('' === $plainPassword || $this->isPasswordTooLong($plainPassword)) { + return false; + } + + if (0 !== strpos($hashedPassword, '$argon')) { + // BCrypt encodes only the first 72 chars + return (72 >= \strlen($plainPassword) || 0 !== strpos($hashedPassword, '$2')) && password_verify($plainPassword, $hashedPassword); + } + + if (\extension_loaded('sodium') && version_compare(\SODIUM_LIBRARY_VERSION, '1.0.14', '>=')) { + return sodium_crypto_pwhash_str_verify($hashedPassword, $plainPassword); + } + + if (\extension_loaded('libsodium') && version_compare(phpversion('libsodium'), '1.0.14', '>=')) { + return \Sodium\crypto_pwhash_str_verify($hashedPassword, $plainPassword); + } + + return password_verify($plainPassword, $hashedPassword); + } + + /** + * {@inheritdoc} + */ + public function needsRehash(string $hashedPassword): bool + { + return password_needs_rehash($hashedPassword, $this->algo, $this->options); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherAwareInterface.php b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherAwareInterface.php new file mode 100644 index 0000000000000..58046bc56c60c --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherAwareInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +/** + * @author Christophe Coevoet + */ +interface PasswordHasherAwareInterface +{ + /** + * Gets the name of the password hasher used to hash the password. + * + * If the method returns null, the standard way to retrieve the password hasher + * will be used instead. + */ + public function getPasswordHasherName(): ?string; +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactory.php b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactory.php new file mode 100644 index 0000000000000..03bbe7350c2cf --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactory.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\Security\Core\Encoder\EncoderAwareInterface; +use Symfony\Component\PasswordHasher\Exception\LogicException; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * A generic hasher factory implementation. + * + * @author Nicolas Grekas + * @author Robin Chalas + */ +class PasswordHasherFactory implements PasswordHasherFactoryInterface +{ + private $passwordHashers; + + public function __construct(array $passwordHashers) + { + $this->passwordHashers = $passwordHashers; + } + + /** + * {@inheritdoc} + */ + public function getPasswordHasher($user): PasswordHasherInterface + { + $hasherKey = null; + + if (($user instanceof PasswordHasherAwareInterface && null !== $hasherName = $user->getPasswordHasherName()) || ($user instanceof EncoderAwareInterface && null !== $hasherName = $user->getEncoderName())) { + if (!\array_key_exists($hasherName, $this->passwordHashers)) { + throw new \RuntimeException(sprintf('The password hasher "%s" was not configured.', $hasherName)); + } + + $hasherKey = $hasherName; + } else { + foreach ($this->passwordHashers as $class => $hasher) { + if ((\is_object($user) && $user instanceof $class) || (!\is_object($user) && (is_subclass_of($user, $class) || $user == $class))) { + $hasherKey = $class; + break; + } + } + } + + if (null === $hasherKey) { + throw new \RuntimeException(sprintf('No password hasher has been configured for account "%s".', \is_object($user) ? get_debug_type($user) : $user)); + } + + if (!$this->passwordHashers[$hasherKey] instanceof PasswordHasherInterface) { + $this->passwordHashers[$hasherKey] = $this->createHasher($this->passwordHashers[$hasherKey]); + } + + return $this->passwordHashers[$hasherKey]; + } + + /** + * Creates the actual hasher instance. + * + * @throws \InvalidArgumentException + */ + private function createHasher(array $config, bool $isExtra = false): PasswordHasherInterface + { + if (isset($config['algorithm'])) { + $rawConfig = $config; + $config = $this->getHasherConfigFromAlgorithm($config); + } + if (!isset($config['class'])) { + throw new \InvalidArgumentException('"class" must be set in '.json_encode($config)); + } + if (!isset($config['arguments'])) { + throw new \InvalidArgumentException('"arguments" must be set in '.json_encode($config)); + } + + $hasher = new $config['class'](...$config['arguments']); + + if ($isExtra || !\in_array($config['class'], [NativePasswordHasher::class, SodiumPasswordHasher::class], true)) { + return $hasher; + } + + if ($rawConfig ?? null) { + $extrapasswordHashers = array_map(function (string $algo) use ($rawConfig): PasswordHasherInterface { + $rawConfig['algorithm'] = $algo; + + return $this->createHasher($rawConfig); + }, ['pbkdf2', $rawConfig['hash_algorithm'] ?? 'sha512']); + } else { + $extrapasswordHashers = [new Pbkdf2PasswordHasher(), new MessageDigestPasswordHasher()]; + } + + return new MigratingPasswordHasher($hasher, ...$extrapasswordHashers); + } + + private function getHasherConfigFromAlgorithm(array $config): array + { + if ('auto' === $config['algorithm']) { + $hasherChain = []; + // "plaintext" is not listed as any leaked hashes could then be used to authenticate directly + foreach ([SodiumPasswordHasher::isSupported() ? 'sodium' : 'native', 'pbkdf2', $config['hash_algorithm']] as $algo) { + $config['algorithm'] = $algo; + $hasherChain[] = $this->createHasher($config, true); + } + + return [ + 'class' => MigratingPasswordHasher::class, + 'arguments' => $hasherChain, + ]; + } + + if ($frompasswordHashers = ($config['migrate_from'] ?? false)) { + unset($config['migrate_from']); + $hasherChain = [$this->createHasher($config, true)]; + + foreach ($frompasswordHashers as $name) { + if ($hasher = $this->passwordHashers[$name] ?? false) { + $hasher = $hasher instanceof PasswordHasherInterface ? $hasher : $this->createHasher($hasher, true); + } else { + $hasher = $this->createHasher(['algorithm' => $name], true); + } + + $hasherChain[] = $hasher; + } + + return [ + 'class' => MigratingPasswordHasher::class, + 'arguments' => $hasherChain, + ]; + } + + switch ($config['algorithm']) { + case 'plaintext': + return [ + 'class' => PlaintextPasswordHasher::class, + 'arguments' => [$config['ignore_case'] ?? false], + ]; + + case 'pbkdf2': + return [ + 'class' => Pbkdf2PasswordHasher::class, + 'arguments' => [ + $config['hash_algorithm'] ?? 'sha512', + $config['encode_as_base64'] ?? true, + $config['iterations'] ?? 1000, + $config['key_length'] ?? 40, + ], + ]; + + case 'bcrypt': + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_BCRYPT; + + return $this->getHasherConfigFromAlgorithm($config); + + case 'native': + return [ + 'class' => NativePasswordHasher::class, + 'arguments' => [ + $config['time_cost'] ?? null, + (($config['memory_cost'] ?? 0) << 10) ?: null, + $config['cost'] ?? null, + ] + (isset($config['native_algorithm']) ? [3 => $config['native_algorithm']] : []), + ]; + + case 'sodium': + return [ + 'class' => SodiumPasswordHasher::class, + 'arguments' => [ + $config['time_cost'] ?? null, + (($config['memory_cost'] ?? 0) << 10) ?: null, + ], + ]; + + case 'argon2i': + if (SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + $config['algorithm'] = 'sodium'; + } elseif (\defined('PASSWORD_ARGON2I')) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_ARGON2I; + } else { + throw new LogicException(sprintf('Algorithm "argon2i" is not available. Either use %s"auto" or upgrade to PHP 7.2+ instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? '"argon2id", ' : '')); + } + + return $this->getHasherConfigFromAlgorithm($config); + + case 'argon2id': + if (($hasSodium = SodiumPasswordHasher::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { + $config['algorithm'] = 'sodium'; + } elseif (\defined('PASSWORD_ARGON2ID')) { + $config['algorithm'] = 'native'; + $config['native_algorithm'] = \PASSWORD_ARGON2ID; + } else { + throw new LogicException(sprintf('Algorithm "argon2id" is not available. Either use %s"auto", upgrade to PHP 7.3+ or use libsodium 1.0.15+ instead.', \defined('PASSWORD_ARGON2I') || $hasSodium ? '"argon2i", ' : '')); + } + + return $this->getHasherConfigFromAlgorithm($config); + } + + return [ + 'class' => MessageDigestPasswordHasher::class, + 'arguments' => [ + $config['algorithm'], + $config['encode_as_base64'] ?? true, + $config['iterations'] ?? 5000, + ], + ]; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactoryInterface.php b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactoryInterface.php new file mode 100644 index 0000000000000..943a4003da5e8 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactoryInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * PasswordHasherFactoryInterface to support different password hashers for different user accounts. + * + * @author Robin Chalas + * @author Johannes M. Schmitt + */ +interface PasswordHasherFactoryInterface +{ + /** + * Returns the password hasher to use for the given user. + * + * @param UserInterface|string $user A UserInterface instance or a class name + * + * @throws \RuntimeException When no password hasher could be found for the user + */ + public function getPasswordHasher($user): PasswordHasherInterface; +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php new file mode 100644 index 0000000000000..dd2e742db79fe --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Exception\LogicException; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * Pbkdf2PasswordHasher uses the PBKDF2 (Password-Based Key Derivation Function 2). + * + * Providing a high level of Cryptographic security, + * PBKDF2 is recommended by the National Institute of Standards and Technology (NIST). + * + * But also warrants a warning, using PBKDF2 (with a high number of iterations) slows down the process. + * PBKDF2 should be used with caution and care. + * + * @author Sebastiaan Stok + * @author Andrew Johnson + * @author Fabien Potencier + */ +final class Pbkdf2PasswordHasher implements LegacyPasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $algorithm; + private $encodeHashAsBase64; + private $iterations = 1; + private $length; + private $encodedLength = -1; + + /** + * @param string $algorithm The digest algorithm to use + * @param bool $encodeHashAsBase64 Whether to base64 encode the password hash + * @param int $iterations The number of iterations to use to stretch the password hash + * @param int $length Length of derived key to create + */ + public function __construct(string $algorithm = 'sha512', bool $encodeHashAsBase64 = true, int $iterations = 1000, int $length = 40) + { + $this->algorithm = $algorithm; + $this->encodeHashAsBase64 = $encodeHashAsBase64; + $this->length = $length; + + try { + $this->encodedLength = \strlen($this->hash('', 'salt')); + } catch (\LogicException $e) { + // ignore unsupported algorithm + } + + $this->iterations = $iterations; + } + + public function hash(string $plainPassword, ?string $salt = null): string + { + if ($this->isPasswordTooLong($plainPassword)) { + throw new InvalidPasswordException(); + } + + if (!\in_array($this->algorithm, hash_algos(), true)) { + throw new LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); + } + + $digest = hash_pbkdf2($this->algorithm, $plainPassword, $salt, $this->iterations, $this->length, true); + + return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); + } + + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool + { + if (\strlen($hashedPassword) !== $this->encodedLength || false !== strpos($hashedPassword, '$')) { + return false; + } + + return !$this->isPasswordTooLong($plainPassword) && hash_equals($hashedPassword, $this->hash($plainPassword, $salt)); + } + + public function needsRehash(string $hashedPassword): bool + { + return false; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/PlaintextPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/PlaintextPasswordHasher.php new file mode 100644 index 0000000000000..bafe6bce898c7 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/PlaintextPasswordHasher.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + +/** + * PlaintextPasswordHasher does not do any hashing but is useful in testing environments. + * + * As this hasher is not cryptographically secure, usage of it in production environments is discouraged. + * + * @author Fabien Potencier + */ +class PlaintextPasswordHasher implements LegacyPasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $ignorePasswordCase; + + /** + * @param bool $ignorePasswordCase Compare password case-insensitive + */ + public function __construct(bool $ignorePasswordCase = false) + { + $this->ignorePasswordCase = $ignorePasswordCase; + } + + /** + * {@inheritdoc} + */ + public function hash(string $plainPassword, ?string $salt = null): string + { + if ($this->isPasswordTooLong($plainPassword)) { + throw new InvalidPasswordException(); + } + + return $this->mergePasswordAndSalt($plainPassword, $salt); + } + + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool + { + if ($this->isPasswordTooLong($plainPassword)) { + return false; + } + + $pass2 = $this->mergePasswordAndSalt($plainPassword, $salt); + + if (!$this->ignorePasswordCase) { + return hash_equals($hashedPassword, $pass2); + } + + return hash_equals(strtolower($hashedPassword), strtolower($pass2)); + } + + public function needsRehash(string $hashedPassword): bool + { + return false; + } + + private function mergePasswordAndSalt(string $password, ?string $salt): string + { + if (empty($salt)) { + return $password; + } + + if (false !== strrpos($salt, '{') || false !== strrpos($salt, '}')) { + throw new \InvalidArgumentException('Cannot use { or } in salt.'); + } + + return $password.'{'.$salt.'}'; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/SodiumPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/SodiumPasswordHasher.php new file mode 100644 index 0000000000000..613cccd03763d --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/SodiumPasswordHasher.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\PasswordHasher\Exception\LogicException; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +/** + * Hashes passwords using libsodium. + * + * @author Robin Chalas + * @author Zan Baldwin + * @author Dominik Müller + */ +final class SodiumPasswordHasher implements PasswordHasherInterface +{ + use CheckPasswordLengthTrait; + + private $opsLimit; + private $memLimit; + + public function __construct(int $opsLimit = null, int $memLimit = null) + { + if (!self::isSupported()) { + throw new LogicException('Libsodium is not available. You should either install the sodium extension or use a different password hasher.'); + } + + $this->opsLimit = $opsLimit ?? max(4, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); + $this->memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); + + if (3 > $this->opsLimit) { + throw new \InvalidArgumentException('$opsLimit must be 3 or greater.'); + } + + if (10 * 1024 > $this->memLimit) { + throw new \InvalidArgumentException('$memLimit must be 10k or greater.'); + } + } + + public static function isSupported(): bool + { + return version_compare(\extension_loaded('sodium') ? \SODIUM_LIBRARY_VERSION : phpversion('libsodium'), '1.0.14', '>='); + } + + public function hash(string $plainPassword): string + { + if ($this->isPasswordTooLong($plainPassword)) { + throw new InvalidPasswordException(); + } + + if (\function_exists('sodium_crypto_pwhash_str')) { + return sodium_crypto_pwhash_str($plainPassword, $this->opsLimit, $this->memLimit); + } + + if (\extension_loaded('libsodium')) { + return \Sodium\crypto_pwhash_str($plainPassword, $this->opsLimit, $this->memLimit); + } + + throw new LogicException('Libsodium is not available. You should either install the sodium extension or use a different password hasher.'); + } + + public function verify(string $hashedPassword, string $plainPassword): bool + { + if ('' === $plainPassword) { + return false; + } + + if ($this->isPasswordTooLong($plainPassword)) { + return false; + } + + if (0 !== strpos($hashedPassword, '$argon')) { + // Accept validating non-argon passwords for seamless migrations + return (72 >= \strlen($plainPassword) || 0 !== strpos($hashedPassword, '$2')) && password_verify($plainPassword, $hashedPassword); + } + + if (\function_exists('sodium_crypto_pwhash_str_verify')) { + return sodium_crypto_pwhash_str_verify($hashedPassword, $plainPassword); + } + + if (\extension_loaded('libsodium')) { + return \Sodium\crypto_pwhash_str_verify($hashedPassword, $plainPassword); + } + + return false; + } + + public function needsRehash(string $hashedPassword): bool + { + if (\function_exists('sodium_crypto_pwhash_str_needs_rehash')) { + return sodium_crypto_pwhash_str_needs_rehash($hashedPassword, $this->opsLimit, $this->memLimit); + } + + if (\extension_loaded('libsodium')) { + return \Sodium\crypto_pwhash_str_needs_rehash($hashedPassword, $this->opsLimit, $this->memLimit); + } + + throw new LogicException('Libsodium is not available. You should either install the sodium extension or use a different password hasher.'); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasher.php b/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasher.php new file mode 100644 index 0000000000000..bb11680665003 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasher.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Hashes passwords based on the user and the PasswordHasherFactory. + * + * @author Ariel Ferrandini + */ +class UserPasswordHasher implements UserPasswordHasherInterface +{ + private $hasherFactory; + + public function __construct(PasswordHasherFactoryInterface $hasherFactory) + { + $this->hasherFactory = $hasherFactory; + } + + public function hashPassword(UserInterface $user, string $plainPassword): string + { + $hasher = $this->hasherFactory->getPasswordHasher($user); + + return $hasher->hash($plainPassword, $user->getSalt()); + } + + /** + * {@inheritdoc} + */ + public function isPasswordValid(UserInterface $user, string $plainPassword): bool + { + if (null === $user->getPassword()) { + return false; + } + + $hasher = $this->hasherFactory->getPasswordHasher($user); + + return $hasher->verify($user->getPassword(), $plainPassword, $user->getSalt()); + } + + /** + * {@inheritdoc} + */ + public function needsRehash(UserInterface $user): bool + { + if (null === $user->getPassword()) { + return false; + } + + $hasher = $this->hasherFactory->getPasswordHasher($user); + + return $hasher->needsRehash($user->getPassword()); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasherInterface.php b/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasherInterface.php new file mode 100644 index 0000000000000..13a5b51a8a119 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasherInterface.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Hasher; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Interface for the user password hasher service. + * + * @author Ariel Ferrandini + */ +interface UserPasswordHasherInterface +{ + /** + * Hashes the plain password for the given user. + */ + public function hashPassword(UserInterface $user, string $plainPassword): string; + + /** + * Checks if the plaintext password matches the user's password. + */ + public function isPasswordValid(UserInterface $user, string $plainPassword): bool; + + /** + * Checks if a password hash would benefit from rehashing. + */ + public function needsRehash(UserInterface $user): bool; +} diff --git a/src/Symfony/Component/PasswordHasher/LICENSE b/src/Symfony/Component/PasswordHasher/LICENSE new file mode 100644 index 0000000000000..9ff2d0d6306da --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/PasswordHasher/LegacyPasswordHasherInterface.php b/src/Symfony/Component/PasswordHasher/LegacyPasswordHasherInterface.php new file mode 100644 index 0000000000000..3faf96d2f4d27 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/LegacyPasswordHasherInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; + +/** + * Provides password hashing and verification capabilities for "legacy" hashers that require external salts. + * + * @author Fabien Potencier + * @author Nicolas Grekas + * @author Robin Chalas + */ +interface LegacyPasswordHasherInterface extends PasswordHasherInterface +{ + /** + * Hashes a plain password. + * + * @return string The hashed password + * + * @throws InvalidPasswordException If the plain password is invalid, e.g. excessively long + */ + public function hash(string $plainPassword, ?string $salt = null): string; + + /** + * Checks that a plain password and a salt match a password hash. + */ + public function verify(string $hashedPassword, string $plainPassword, ?string $salt = null): bool; +} diff --git a/src/Symfony/Component/PasswordHasher/PasswordHasherInterface.php b/src/Symfony/Component/PasswordHasher/PasswordHasherInterface.php new file mode 100644 index 0000000000000..6b3575783891f --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/PasswordHasherInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; + +/** + * Provides password hashing capabilities. + * + * @author Robin Chalas + * @author Fabien Potencier + * @author Nicolas Grekas + */ +interface PasswordHasherInterface +{ + public const MAX_PASSWORD_LENGTH = 4096; + + /** + * Hashes a plain password. + * + * @throws InvalidPasswordException When the plain password is invalid, e.g. excessively long + */ + public function hash(string $plainPassword): string; + + /** + * Verifies a plain password against a hash. + */ + public function verify(string $hashedPassword, string $plainPassword): bool; + + /** + * Checks if a password hash would benefit from rehashing. + */ + public function needsRehash(string $hashedPassword): bool; +} diff --git a/src/Symfony/Component/PasswordHasher/README.md b/src/Symfony/Component/PasswordHasher/README.md new file mode 100644 index 0000000000000..6a54ecb3355bf --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/README.md @@ -0,0 +1,40 @@ +PasswordHasher Component +======================== + +The PasswordHasher component provides secure password hashing utilities. + +Getting Started +--------------- + +``` +$ composer require symfony/password-hasher +``` + +```php +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; + +// Configure different password hashers via the factory +$factory = new PasswordHasherFactory([ + 'common' => ['algorithm' => 'bcrypt'], + 'memory-hard' => ['algorithm' => 'sodium'], +]); + +// Retrieve the right password hasher by its name +$passwordHasher = $factory->getPasswordHasher('common'); + +// Hash a plain password +$hash = $passwordHasher->hash('plain'); // returns a bcrypt hash + +// Verify that a given plain password matches the hash +$passwordHasher->verify($hash, 'wrong'); // returns false +$passwordHasher->verify($hash, 'plain'); // returns true (valid) +``` + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/password-hasher.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/PasswordHasher/Tests/Command/UserPasswordHashCommandTest.php b/src/Symfony/Component/PasswordHasher/Tests/Command/UserPasswordHashCommandTest.php new file mode 100644 index 0000000000000..03cca7acd9ec6 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Command/UserPasswordHashCommandTest.php @@ -0,0 +1,362 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\Command\UserPasswordHasherCommand; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\PasswordHasher\Command\UserPasswordHashCommand; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; + +class UserPasswordHashCommandTest extends TestCase +{ + /** @var CommandTester */ + private $passwordHasherCommandTester; + + public function testEncodePasswordEmptySalt() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Symfony\Component\Security\Core\User\User', + '--empty-salt' => true, + ], ['decorated' => false]); + + $this->assertStringContainsString(' Password hash password', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodeNoPasswordNoInteraction() + { + $statusCode = $this->passwordHasherCommandTester->execute([ + ], ['interactive' => false]); + + $this->assertStringContainsString('[ERROR] The password must not be empty.', $this->passwordHasherCommandTester->getDisplay()); + $this->assertEquals(1, $statusCode); + } + + public function testEncodePasswordBcrypt() + { + $this->setupBcrypt(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Bcrypt\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = new NativePasswordHasher(null, null, 17, \PASSWORD_BCRYPT); + preg_match('# Password hash\s{1,}([\w+\/$.]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', null)); + } + + public function testEncodePasswordArgon2i() + { + if (!($sodium = SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { + $this->markTestSkipped('Argon2i algorithm not available.'); + } + $this->setupArgon2i(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Argon2i\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = $sodium ? new SodiumPasswordHasher() : new NativePasswordHasher(null, null, null, \PASSWORD_ARGON2I); + preg_match('# Password hash\s+(\$argon2i?\$[\w,=\$+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', null)); + } + + public function testEncodePasswordArgon2id() + { + if (!($sodium = (SodiumPasswordHasher::isSupported() && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13'))) && !\defined('PASSWORD_ARGON2ID')) { + $this->markTestSkipped('Argon2id algorithm not available.'); + } + $this->setupArgon2id(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Argon2id\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = $sodium ? new SodiumPasswordHasher() : new NativePasswordHasher(null, null, null, \PASSWORD_ARGON2ID); + preg_match('# Password hash\s+(\$argon2id?\$[\w,=\$+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', null)); + } + + public function testEncodePasswordNative() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Native\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = new NativePasswordHasher(); + preg_match('# Password hash\s{1,}([\w+\/$.,=]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', null)); + } + + public function testEncodePasswordSodium() + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Libsodium is not available.'); + } + $this->setupSodium(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Sodium\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + preg_match('# Password hash\s+(\$?\$[\w,=\$+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue((new SodiumPasswordHasher())->verify($hash, 'password', null)); + } + + public function testEncodePasswordPbkdf2() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Custom\Class\Pbkdf2\User', + ], ['interactive' => false]); + + $output = $this->passwordHasherCommandTester->getDisplay(); + $this->assertStringContainsString('Password hashing succeeded', $output); + + $hasher = new Pbkdf2PasswordHasher('sha512', true, 1000); + preg_match('# Password hash\s{1,}([\w+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + preg_match('# Generated salt\s{1,}([\w+\/]+={0,2})\s+#', $output, $matches); + $salt = $matches[1]; + $this->assertTrue($hasher->verify($hash, 'password', $salt)); + } + + public function testEncodePasswordOutput() + { + $this->passwordHasherCommandTester->execute( + [ + 'password' => 'p@ssw0rd', + ], ['interactive' => false] + ); + + $this->assertStringContainsString('Password hashing succeeded', $this->passwordHasherCommandTester->getDisplay()); + $this->assertStringContainsString(' Password hash p@ssw0rd', $this->passwordHasherCommandTester->getDisplay()); + $this->assertStringContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordEmptySaltOutput() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Symfony\Component\Security\Core\User\User', + '--empty-salt' => true, + ]); + + $this->assertStringContainsString('Password hashing succeeded', $this->passwordHasherCommandTester->getDisplay()); + $this->assertStringContainsString(' Password hash p@ssw0rd', $this->passwordHasherCommandTester->getDisplay()); + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordNativeOutput() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Native\User', + ], ['interactive' => false]); + + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordArgon2iOutput() + { + if (!(SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2I')) { + $this->markTestSkipped('Argon2i algorithm not available.'); + } + + $this->setupArgon2i(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Argon2i\User', + ], ['interactive' => false]); + + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordArgon2idOutput() + { + if (!(SodiumPasswordHasher::isSupported() && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) && !\defined('PASSWORD_ARGON2ID')) { + $this->markTestSkipped('Argon2id algorithm not available.'); + } + + $this->setupArgon2id(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Argon2id\User', + ], ['interactive' => false]); + + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordSodiumOutput() + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Libsodium is not available.'); + } + + $this->setupSodium(); + $this->passwordHasherCommandTester->execute([ + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Sodium\User', + ], ['interactive' => false]); + + $this->assertStringNotContainsString(' Generated salt ', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testEncodePasswordNoConfigForGivenUserClass() + { + $this->expectException('\RuntimeException'); + $this->expectExceptionMessage('No password hasher has been configured for account "Foo\Bar\User".'); + + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + 'user-class' => 'Foo\Bar\User', + ], ['interactive' => false]); + } + + public function testEncodePasswordAsksNonProvidedUserClass() + { + $this->passwordHasherCommandTester->setInputs(['Custom\Class\Pbkdf2\User', "\n"]); + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + ], ['decorated' => false]); + + $this->assertStringContainsString(<<passwordHasherCommandTester->getDisplay(true)); + } + + public function testNonInteractiveEncodePasswordUsesFirstUserClass() + { + $this->passwordHasherCommandTester->execute([ + 'password' => 'password', + ], ['interactive' => false]); + + $this->assertStringContainsString('Hasher used Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher', $this->passwordHasherCommandTester->getDisplay()); + } + + public function testThrowsExceptionOnNoConfiguredHashers() + { + $this->expectException('RuntimeException'); + $this->expectExceptionMessage('There are no configured password hashers for the "security" extension.'); + + $tester = new CommandTester(new UserPasswordHashCommand($this->getMockBuilder(PasswordHasherFactoryInterface::class)->getMock(), [])); + $tester->execute([ + 'password' => 'password', + ], ['interactive' => false]); + } + + protected function setUp(): void + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + $hasherFactory = new PasswordHasherFactory([ + User::class => ['algorithm' => 'plaintext'], + 'Custom\Class\Native\User' => ['algorithm' => 'native', 'cost' => 10], + 'Custom\Class\Pbkdf2\User' => ['algorithm' => 'pbkdf2', 'hash_algorithm' => 'sha512', 'iterations' => 1000, 'encode_as_base64' => true], + 'Custom\Class\Test\User' => ['algorithm' => 'test'], + ]); + + $this->passwordHasherCommandTester = new CommandTester(new UserPasswordHashCommand( + $hasherFactory, + [User::class, 'Custom\Class\Native\User', 'Custom\Class\Pbkdf2\User', 'Custom\Class\Test\User'] + )); + } + + protected function tearDown(): void + { + $this->passwordHasherCommandTester = null; + } + + private function setupArgon2i() + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + + $hasherFactory = new PasswordHasherFactory([ + 'Custom\Class\Argon2i\User' => ['algorithm' => 'argon2i'], + ]); + + $this->passwordHasherCommandTester = new CommandTester( + new UserPasswordHashCommand($hasherFactory, ['Custom\Class\Argon2i\User']) + ); + } + + private function setupArgon2id() + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + + $hasherFactory = new PasswordHasherFactory([ + 'Custom\Class\Argon2id\User' => ['algorithm' => 'argon2id'], + ]); + + $this->passwordHasherCommandTester = new CommandTester( + new UserPasswordHashCommand($hasherFactory, ['Custom\Class\Argon2id\User']) + ); + } + + private function setupBcrypt() + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + + $hasherFactory = new PasswordHasherFactory([ + 'Custom\Class\Bcrypt\User' => ['algorithm' => 'bcrypt'], + ]); + + $this->passwordHasherCommandTester = new CommandTester(new UserPasswordHashCommand( + $hasherFactory, + [User::class, 'Custom\Class\Pbkdf2\User', 'Custom\Class\Test\User'] + )); + } + + private function setupSodium() + { + putenv('COLUMNS='.(119 + \strlen(\PHP_EOL))); + + $hasherFactory = new PasswordHasherFactory([ + 'Custom\Class\Sodium\User' => ['algorithm' => 'sodium'], + ]); + + $this->passwordHasherCommandTester = new CommandTester( + new UserPasswordHashCommand($hasherFactory, ['Custom\Class\Sodium\User']) + ); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/MessageDigestPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/MessageDigestPasswordHasherTest.php new file mode 100644 index 0000000000000..a6c66aee213ec --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/MessageDigestPasswordHasherTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Hasher\MessageDigestPasswordHasher; + +class MessageDigestPasswordHasherTest extends TestCase +{ + public function testVerify() + { + $hasher = new MessageDigestPasswordHasher('sha256', false, 1); + + $this->assertTrue($hasher->verify(hash('sha256', 'password'), 'password', '')); + } + + public function testHash() + { + $hasher = new MessageDigestPasswordHasher('sha256', false, 1); + $this->assertSame(hash('sha256', 'password'), $hasher->hash('password', '')); + + $hasher = new MessageDigestPasswordHasher('sha256', true, 1); + $this->assertSame(base64_encode(hash('sha256', 'password', true)), $hasher->hash('password', '')); + + $hasher = new MessageDigestPasswordHasher('sha256', false, 2); + $this->assertSame(hash('sha256', hash('sha256', 'password', true).'password'), $hasher->hash('password', '')); + } + + public function testHashAlgorithmDoesNotExist() + { + $this->expectException('LogicException'); + $hasher = new MessageDigestPasswordHasher('foobar'); + $hasher->hash('password', ''); + } + + public function testHashLength() + { + $this->expectException(InvalidPasswordException::class); + $hasher = new MessageDigestPasswordHasher(); + + $hasher->hash(str_repeat('a', 5000), 'salt'); + } + + public function testCheckPasswordLength() + { + $hasher = new MessageDigestPasswordHasher(); + + $this->assertFalse($hasher->verify('encoded', str_repeat('a', 5000), 'salt')); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/MigratingPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/MigratingPasswordHasherTest.php new file mode 100644 index 0000000000000..145a5cc34e33d --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/MigratingPasswordHasherTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Hasher\MigratingPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +class MigratingPasswordHasherTest extends TestCase +{ + public function testValidation() + { + $bestHasher = new NativePasswordHasher(4, 12000, 4); + + $extraHasher = $this->createMock(PasswordHasherInterface::class); + $extraHasher->expects($this->never())->method('hash'); + $extraHasher->expects($this->never())->method('verify'); + $extraHasher->expects($this->never())->method('needsRehash'); + + $hasher = new MigratingPasswordHasher($bestHasher, $extraHasher); + + $this->assertTrue($hasher->needsRehash('foo')); + + $hash = $hasher->hash('foo', 'salt'); + $this->assertFalse($hasher->needsRehash($hash)); + + $this->assertTrue($hasher->verify($hash, 'foo', 'salt')); + $this->assertFalse($hasher->verify($hash, 'bar', 'salt')); + } + + public function testFallback() + { + $bestHasher = new NativePasswordHasher(4, 12000, 4); + + $extraHasher1 = $this->createMock(PasswordHasherInterface::class); + $extraHasher1->expects($this->any()) + ->method('verify') + ->with('abc', 'foo', 'salt') + ->willReturn(true); + + $hasher = new MigratingPasswordHasher($bestHasher, $extraHasher1); + + $this->assertTrue($hasher->verify('abc', 'foo', 'salt')); + + $extraHasher2 = $this->createMock(PasswordHasherInterface::class); + $extraHasher2->expects($this->any()) + ->method('verify') + ->willReturn(false); + + $hasher = new MigratingPasswordHasher($bestHasher, $extraHasher2); + + $this->assertFalse($hasher->verify('abc', 'foo', 'salt')); + + $hasher = new MigratingPasswordHasher($bestHasher, $extraHasher2, $extraHasher1); + + $this->assertTrue($hasher->verify('abc', 'foo', 'salt')); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/NativePasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/NativePasswordHasherTest.php new file mode 100644 index 0000000000000..90f48267ce36f --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/NativePasswordHasherTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; + +/** + * @author Elnur Abdurrakhimov + */ +class NativePasswordHasherTest extends TestCase +{ + public function testCostBelowRange() + { + $this->expectException('InvalidArgumentException'); + new NativePasswordHasher(null, null, 3); + } + + public function testCostAboveRange() + { + $this->expectException('InvalidArgumentException'); + new NativePasswordHasher(null, null, 32); + } + + /** + * @dataProvider validRangeData + */ + public function testCostInRange($cost) + { + $this->assertInstanceOf(NativePasswordHasher::class, new NativePasswordHasher(null, null, $cost)); + } + + public function validRangeData() + { + $costs = range(4, 31); + array_walk($costs, function (&$cost) { $cost = [$cost]; }); + + return $costs; + } + + public function testValidation() + { + $hasher = new NativePasswordHasher(); + $result = $hasher->hash('password', null); + $this->assertTrue($hasher->verify($result, 'password', null)); + $this->assertFalse($hasher->verify($result, 'anotherPassword', null)); + $this->assertFalse($hasher->verify($result, '', null)); + } + + public function testNonArgonValidation() + { + $hasher = new NativePasswordHasher(); + $this->assertTrue($hasher->verify('$5$abcdefgh$ZLdkj8mkc2XVSrPVjskDAgZPGjtj1VGVaa1aUkrMTU/', 'password', null)); + $this->assertFalse($hasher->verify('$5$abcdefgh$ZLdkj8mkc2XVSrPVjskDAgZPGjtj1VGVaa1aUkrMTU/', 'anotherPassword', null)); + $this->assertTrue($hasher->verify('$6$abcdefgh$yVfUwsw5T.JApa8POvClA1pQ5peiq97DUNyXCZN5IrF.BMSkiaLQ5kvpuEm/VQ1Tvh/KV2TcaWh8qinoW5dhA1', 'password', null)); + $this->assertFalse($hasher->verify('$6$abcdefgh$yVfUwsw5T.JApa8POvClA1pQ5peiq97DUNyXCZN5IrF.BMSkiaLQ5kvpuEm/VQ1Tvh/KV2TcaWh8qinoW5dhA1', 'anotherPassword', null)); + } + + public function testConfiguredAlgorithm() + { + $hasher = new NativePasswordHasher(null, null, null, \PASSWORD_BCRYPT); + $result = $hasher->hash('password', null); + $this->assertTrue($hasher->verify($result, 'password', null)); + $this->assertStringStartsWith('$2', $result); + } + + public function testConfiguredAlgorithmWithLegacyConstValue() + { + $hasher = new NativePasswordHasher(null, null, null, '1'); + $result = $hasher->hash('password', null); + $this->assertTrue($hasher->verify($result, 'password', null)); + $this->assertStringStartsWith('$2', $result); + } + + public function testCheckPasswordLength() + { + $hasher = new NativePasswordHasher(null, null, 4); + $result = password_hash(str_repeat('a', 72), \PASSWORD_BCRYPT, ['cost' => 4]); + + $this->assertFalse($hasher->verify($result, str_repeat('a', 73), 'salt')); + $this->assertTrue($hasher->verify($result, str_repeat('a', 72), 'salt')); + } + + public function testNeedsRehash() + { + $hasher = new NativePasswordHasher(4, 11000, 4); + + $this->assertTrue($hasher->needsRehash('dummyhash')); + + $hash = $hasher->hash('foo', 'salt'); + $this->assertFalse($hasher->needsRehash($hash)); + + $hasher = new NativePasswordHasher(5, 11000, 5); + $this->assertTrue($hasher->needsRehash($hash)); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php new file mode 100644 index 0000000000000..6c5205e37465f --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\MessageDigestPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\MigratingPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Security\Core\User\UserInterface; + +class PasswordHasherFactoryTest extends TestCase +{ + public function testGetHasherWithMessageDigestHasher() + { + $factory = new PasswordHasherFactory([UserInterface::class => [ + 'class' => MessageDigestPasswordHasher::class, + 'arguments' => ['sha512', true, 5], + ]]); + + $hasher = $factory->getPasswordHasher($this->createMock(UserInterface::class)); + $expectedHasher = new MessageDigestPasswordHasher('sha512', true, 5); + + $this->assertEquals($expectedHasher->hash('foo', 'moo'), $hasher->hash('foo', 'moo')); + } + + public function testGetHasherWithService() + { + $factory = new PasswordHasherFactory([ + UserInterface::class => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher($this->createMock(UserInterface::class)); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + + $hasher = $factory->getPasswordHasher(new User('user', 'pass')); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetHasherWithClassName() + { + $factory = new PasswordHasherFactory([ + UserInterface::class => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher(SomeChildUser::class); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetHasherConfiguredForConcreteClassWithService() + { + $factory = new PasswordHasherFactory([ + 'Symfony\Component\Security\Core\User\User' => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher(new User('user', 'pass')); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetHasherConfiguredForConcreteClassWithClassName() + { + $factory = new PasswordHasherFactory([ + 'Symfony\Component\PasswordHasher\Tests\Hasher\SomeUser' => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher(SomeChildUser::class); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetNamedHasherForHasherAware() + { + $factory = new PasswordHasherFactory([ + HasherAwareUser::class => new MessageDigestPasswordHasher('sha256'), + 'hasher_name' => new MessageDigestPasswordHasher('sha1'), + ]); + + $hasher = $factory->getPasswordHasher(new HasherAwareUser('user', 'pass')); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetNullNamedHasherForHasherAware() + { + $factory = new PasswordHasherFactory([ + HasherAwareUser::class => new MessageDigestPasswordHasher('sha1'), + 'hasher_name' => new MessageDigestPasswordHasher('sha256'), + ]); + + $user = new HasherAwareUser('mathilde', 'krogulec'); + $user->hasherName = null; + $hasher = $factory->getPasswordHasher($user); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testGetInvalidNamedHasherForHasherAware() + { + $this->expectException('RuntimeException'); + $factory = new PasswordHasherFactory([ + HasherAwareUser::class => new MessageDigestPasswordHasher('sha1'), + 'hasher_name' => new MessageDigestPasswordHasher('sha256'), + ]); + + $user = new HasherAwareUser('user', 'pass'); + $user->hasherName = 'invalid_hasher_name'; + $factory->getPasswordHasher($user); + } + + public function testGetHasherForHasherAwareWithClassName() + { + $factory = new PasswordHasherFactory([ + HasherAwareUser::class => new MessageDigestPasswordHasher('sha1'), + 'hasher_name' => new MessageDigestPasswordHasher('sha256'), + ]); + + $hasher = $factory->getPasswordHasher(HasherAwareUser::class); + $expectedHasher = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedHasher->hash('foo', ''), $hasher->hash('foo', '')); + } + + public function testMigrateFrom() + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Sodium is not available'); + } + + $factory = new PasswordHasherFactory([ + 'digest_hasher' => $digest = new MessageDigestPasswordHasher('sha256'), + SomeUser::class => ['algorithm' => 'sodium', 'migrate_from' => ['bcrypt', 'digest_hasher']], + ]); + + $hasher = $factory->getPasswordHasher(SomeUser::class); + $this->assertInstanceOf(MigratingPasswordHasher::class, $hasher); + + $this->assertTrue($hasher->verify((new SodiumPasswordHasher())->hash('foo', null), 'foo', null)); + $this->assertTrue($hasher->verify((new NativePasswordHasher(null, null, null, \PASSWORD_BCRYPT))->hash('foo', null), 'foo', null)); + $this->assertTrue($hasher->verify($digest->hash('foo', null), 'foo', null)); + $this->assertStringStartsWith(\SODIUM_CRYPTO_PWHASH_STRPREFIX, $hasher->hash('foo', null)); + } + + public function testDefaultMigratingHashers() + { + $this->assertInstanceOf( + MigratingPasswordHasher::class, + (new PasswordHasherFactory([SomeUser::class => ['class' => NativePasswordHasher::class, 'arguments' => []]]))->getPasswordHasher(SomeUser::class) + ); + + $this->assertInstanceOf( + MigratingPasswordHasher::class, + (new PasswordHasherFactory([SomeUser::class => ['algorithm' => 'bcrypt', 'cost' => 11]]))->getPasswordHasher(SomeUser::class) + ); + + if (!SodiumPasswordHasher::isSupported()) { + return; + } + + $this->assertInstanceOf( + MigratingPasswordHasher::class, + (new PasswordHasherFactory([SomeUser::class => ['class' => SodiumPasswordHasher::class, 'arguments' => []]]))->getPasswordHasher(SomeUser::class) + ); + } +} + +class SomeUser implements UserInterface +{ + public function getRoles(): array + { + } + + public function getPassword(): ?string + { + } + + public function getSalt(): ?string + { + } + + public function getUsername(): string + { + } + + public function eraseCredentials() + { + } +} + +class SomeChildUser extends SomeUser +{ +} + +class HasherAwareUser extends SomeUser implements PasswordHasherAwareInterface +{ + public $hasherName = 'hasher_name'; + + public function getPasswordHasherName(): ?string + { + return $this->hasherName; + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/Pbkdf2PasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/Pbkdf2PasswordHasherTest.php new file mode 100644 index 0000000000000..50f9c8d13e864 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/Pbkdf2PasswordHasherTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; + +class Pbkdf2PasswordHasherTest extends TestCase +{ + public function testVerify() + { + $hasher = new Pbkdf2PasswordHasher('sha256', false, 1, 40); + + $this->assertTrue($hasher->verify('c1232f10f62715fda06ae7c0a2037ca19b33cf103b727ba56d870c11f290a2ab106974c75607c8a3', 'password', '')); + } + + public function testHash() + { + $hasher = new Pbkdf2PasswordHasher('sha256', false, 1, 40); + $this->assertSame('c1232f10f62715fda06ae7c0a2037ca19b33cf103b727ba56d870c11f290a2ab106974c75607c8a3', $hasher->hash('password', '')); + + $hasher = new Pbkdf2PasswordHasher('sha256', true, 1, 40); + $this->assertSame('wSMvEPYnFf2gaufAogN8oZszzxA7cnulbYcMEfKQoqsQaXTHVgfIow==', $hasher->hash('password', '')); + + $hasher = new Pbkdf2PasswordHasher('sha256', false, 2, 40); + $this->assertSame('8bc2f9167a81cdcfad1235cd9047f1136271c1f978fcfcb35e22dbeafa4634f6fd2214218ed63ebb', $hasher->hash('password', '')); + } + + public function testHashAlgorithmDoesNotExist() + { + $this->expectException('LogicException'); + $hasher = new Pbkdf2PasswordHasher('foobar'); + $hasher->hash('password', ''); + } + + public function testHashLength() + { + $this->expectException(InvalidPasswordException::class); + $hasher = new Pbkdf2PasswordHasher('foobar'); + + $hasher->hash(str_repeat('a', 5000), 'salt'); + } + + public function testCheckPasswordLength() + { + $hasher = new Pbkdf2PasswordHasher('foobar'); + + $this->assertFalse($hasher->verify('encoded', str_repeat('a', 5000), 'salt')); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/PlaintextPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PlaintextPasswordHasherTest.php new file mode 100644 index 0000000000000..dc24db632ab16 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/PlaintextPasswordHasherTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; + +class PlaintextPasswordHasherTest extends TestCase +{ + public function testVerify() + { + $hasher = new PlaintextPasswordHasher(); + + $this->assertTrue($hasher->verify('foo', 'foo', '')); + $this->assertFalse($hasher->verify('bar', 'foo', '')); + $this->assertFalse($hasher->verify('FOO', 'foo', '')); + + $hasher = new PlaintextPasswordHasher(true); + + $this->assertTrue($hasher->verify('foo', 'foo', '')); + $this->assertFalse($hasher->verify('bar', 'foo', '')); + $this->assertTrue($hasher->verify('FOO', 'foo', '')); + } + + public function testHash() + { + $hasher = new PlaintextPasswordHasher(); + + $this->assertSame('foo', $hasher->hash('foo', '')); + } + + public function testHashLength() + { + $this->expectException(InvalidPasswordException::class); + $hasher = new PlaintextPasswordHasher(); + + $hasher->hash(str_repeat('a', 5000), 'salt'); + } + + public function testCheckPasswordLength() + { + $hasher = new PlaintextPasswordHasher(); + + $this->assertFalse($hasher->verify('encoded', str_repeat('a', 5000), 'salt')); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/SodiumPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/SodiumPasswordHasherTest.php new file mode 100644 index 0000000000000..2da309ae92dea --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/SodiumPasswordHasherTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; + +class SodiumPasswordHasherTest extends TestCase +{ + protected function setUp(): void + { + if (!SodiumPasswordHasher::isSupported()) { + $this->markTestSkipped('Libsodium is not available.'); + } + } + + public function testValidation() + { + $hasher = new SodiumPasswordHasher(); + $result = $hasher->hash('password', null); + $this->assertTrue($hasher->verify($result, 'password', null)); + $this->assertFalse($hasher->verify($result, 'anotherPassword', null)); + $this->assertFalse($hasher->verify($result, '', null)); + } + + public function testBCryptValidation() + { + $hasher = new SodiumPasswordHasher(); + $this->assertTrue($hasher->verify('$2y$04$M8GDODMoGQLQRpkYCdoJh.lbiZPee3SZI32RcYK49XYTolDGwoRMm', 'abc', null)); + } + + public function testNonArgonValidation() + { + $hasher = new SodiumPasswordHasher(); + $this->assertTrue($hasher->verify('$5$abcdefgh$ZLdkj8mkc2XVSrPVjskDAgZPGjtj1VGVaa1aUkrMTU/', 'password', null)); + $this->assertFalse($hasher->verify('$5$abcdefgh$ZLdkj8mkc2XVSrPVjskDAgZPGjtj1VGVaa1aUkrMTU/', 'anotherPassword', null)); + $this->assertTrue($hasher->verify('$6$abcdefgh$yVfUwsw5T.JApa8POvClA1pQ5peiq97DUNyXCZN5IrF.BMSkiaLQ5kvpuEm/VQ1Tvh/KV2TcaWh8qinoW5dhA1', 'password', null)); + $this->assertFalse($hasher->verify('$6$abcdefgh$yVfUwsw5T.JApa8POvClA1pQ5peiq97DUNyXCZN5IrF.BMSkiaLQ5kvpuEm/VQ1Tvh/KV2TcaWh8qinoW5dhA1', 'anotherPassword', null)); + } + + public function testHashLength() + { + $this->expectException(InvalidPasswordException::class); + $hasher = new SodiumPasswordHasher(); + $hasher->hash(str_repeat('a', 4097), 'salt'); + } + + public function testCheckPasswordLength() + { + $hasher = new SodiumPasswordHasher(); + $result = $hasher->hash(str_repeat('a', 4096), null); + $this->assertFalse($hasher->verify($result, str_repeat('a', 4097), null)); + $this->assertTrue($hasher->verify($result, str_repeat('a', 4096), null)); + } + + public function testUserProvidedSaltIsNotUsed() + { + $hasher = new SodiumPasswordHasher(); + $result = $hasher->hash('password', 'salt'); + $this->assertTrue($hasher->verify($result, 'password', 'anotherSalt')); + } + + public function testNeedsRehash() + { + $hasher = new SodiumPasswordHasher(4, 11000); + + $this->assertTrue($hasher->needsRehash('dummyhash')); + + $hash = $hasher->hash('foo', 'salt'); + $this->assertFalse($hasher->needsRehash($hash)); + + $hasher = new SodiumPasswordHasher(5, 11000); + $this->assertTrue($hasher->needsRehash($hash)); + } +} diff --git a/src/Symfony/Component/PasswordHasher/Tests/Hasher/UserPasswordHasherTest.php b/src/Symfony/Component/PasswordHasher/Tests/Hasher/UserPasswordHasherTest.php new file mode 100644 index 0000000000000..899723f2e45cb --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/Tests/Hasher/UserPasswordHasherTest.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PasswordHasher\Tests\Hasher; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; + +class UserPasswordHasherTest extends TestCase +{ + public function testHash() + { + $userMock = $this->createMock('Symfony\Component\Security\Core\User\UserInterface'); + $userMock->expects($this->any()) + ->method('getSalt') + ->willReturn('userSalt'); + + $mockHasher = $this->createMock(PasswordHasherInterface::class); + $mockHasher->expects($this->any()) + ->method('hash') + ->with($this->equalTo('plainPassword'), $this->equalTo('userSalt')) + ->willReturn('hash'); + + $mockPasswordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $mockPasswordHasherFactory->expects($this->any()) + ->method('getPasswordHasher') + ->with($this->equalTo($userMock)) + ->willReturn($mockHasher); + + $passwordHasher = new UserPasswordHasher($mockPasswordHasherFactory); + + $encoded = $passwordHasher->hashPassword($userMock, 'plainPassword'); + $this->assertEquals('hash', $encoded); + } + + public function testVerify() + { + $userMock = $this->createMock(UserInterface::class); + $userMock->expects($this->any()) + ->method('getSalt') + ->willReturn('userSalt'); + $userMock->expects($this->any()) + ->method('getPassword') + ->willReturn('hash'); + + $mockHasher = $this->createMock(PasswordHasherInterface::class); + $mockHasher->expects($this->any()) + ->method('verify') + ->with($this->equalTo('hash'), $this->equalTo('plainPassword'), $this->equalTo('userSalt')) + ->willReturn(true); + + $mockPasswordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $mockPasswordHasherFactory->expects($this->any()) + ->method('getPasswordHasher') + ->with($this->equalTo($userMock)) + ->willReturn($mockHasher); + + $passwordHasher = new UserPasswordHasher($mockPasswordHasherFactory); + + $isValid = $passwordHasher->isPasswordValid($userMock, 'plainPassword'); + $this->assertTrue($isValid); + } + + public function testNeedsRehash() + { + $user = new User('username', null); + $hasher = new NativePasswordHasher(4, 20000, 4); + + $mockPasswordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $mockPasswordHasherFactory->expects($this->any()) + ->method('getPasswordHasher') + ->with($user) + ->will($this->onConsecutiveCalls($hasher, $hasher, new NativePasswordHasher(5, 20000, 5), $hasher)); + + $passwordHasher = new UserPasswordHasher($mockPasswordHasherFactory); + + $user->setPassword($passwordHasher->hashPassword($user, 'foo', 'salt')); + $this->assertFalse($passwordHasher->needsRehash($user)); + $this->assertTrue($passwordHasher->needsRehash($user)); + $this->assertFalse($passwordHasher->needsRehash($user)); + } +} diff --git a/src/Symfony/Component/PasswordHasher/composer.json b/src/Symfony/Component/PasswordHasher/composer.json new file mode 100644 index 0000000000000..2ed22ee7066d5 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/password-hasher", + "type": "library", + "description": "Provides password hashing utilities", + "keywords": ["password", "hashing"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "symfony/security-core": "^5.3", + "symfony/console": "^5" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\PasswordHasher\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/PasswordHasher/phpunit.xml.dist b/src/Symfony/Component/PasswordHasher/phpunit.xml.dist new file mode 100644 index 0000000000000..ee4c67f3058c7 --- /dev/null +++ b/src/Symfony/Component/PasswordHasher/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index 43efcebec4136..7e0b6b2a337eb 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 5.3 --- + * Deprecate all classes in the `Core\Encoder\` sub-namespace, use the `PasswordHasher` component instead * Deprecate the `SessionInterface $session` constructor argument of `SessionTokenStorage`, inject a `\Symfony\Component\HttpFoundation\RequestStack $requestStack` instead * Deprecate the `session` service provided by the ServiceLocator injected in `UsageTrackingTokenStorage`, provide a `request_stack` service instead * Deprecate using `SessionTokenStorage` outside a request context, it will throw a `SessionNotFoundException` in Symfony 6.0 diff --git a/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php b/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php index e91c5d8144f6c..c4099603ef59f 100644 --- a/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php +++ b/src/Symfony/Component/Security/Core/Authentication/AuthenticationProviderManager.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Core\Authentication; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\AuthenticationEvents; @@ -18,6 +19,7 @@ use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent; use Symfony\Component\Security\Core\Exception\AccountStatusException; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\ProviderNotFoundException; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -89,6 +91,8 @@ public function authenticate(TokenInterface $token) break; } catch (AuthenticationException $e) { $lastException = $e; + } catch (InvalidPasswordException $e) { + $lastException = new BadCredentialsException('Bad credentials.', 0, $e); } } diff --git a/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php b/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php index c65a9505526f7..26beb6b945ee5 100644 --- a/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php +++ b/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php @@ -20,6 +20,7 @@ use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; /** * DaoAuthenticationProvider uses a UserProviderInterface to retrieve the user @@ -29,14 +30,21 @@ */ class DaoAuthenticationProvider extends UserAuthenticationProvider { - private $encoderFactory; + private $hasherFactory; private $userProvider; - public function __construct(UserProviderInterface $userProvider, UserCheckerInterface $userChecker, string $providerKey, EncoderFactoryInterface $encoderFactory, bool $hideUserNotFoundExceptions = true) + /** + * @param PasswordHasherFactoryInterface $hasherFactory + */ + public function __construct(UserProviderInterface $userProvider, UserCheckerInterface $userChecker, string $providerKey, $hasherFactory, bool $hideUserNotFoundExceptions = true) { parent::__construct($userChecker, $providerKey, $hideUserNotFoundExceptions); - $this->encoderFactory = $encoderFactory; + if ($hasherFactory instanceof EncoderFactoryInterface) { + trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); + } + + $this->hasherFactory = $hasherFactory; $this->userProvider = $userProvider; } @@ -59,14 +67,29 @@ protected function checkAuthentication(UserInterface $user, UsernamePasswordToke throw new BadCredentialsException('The presented password is invalid.'); } - $encoder = $this->encoderFactory->getEncoder($user); + // deprecated since Symfony 5.3 + if ($this->hasherFactory instanceof EncoderFactoryInterface) { + $encoder = $this->hasherFactory->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())); + } + + return; + } + + $hasher = $this->hasherFactory->getPasswordHasher($user); - if (!$encoder->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) { + if (!$hasher->verify($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())); + if ($this->userProvider instanceof PasswordUpgraderInterface && $hasher->needsRehash($user->getPassword())) { + $this->userProvider->upgradePassword($user, $hasher->hash($presentedPassword, $user->getSalt())); } } } diff --git a/src/Symfony/Component/Security/Core/Encoder/BasePasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/BasePasswordEncoder.php index e067a48a37be3..9c014d9ee3f4a 100644 --- a/src/Symfony/Component/Security/Core/Encoder/BasePasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/BasePasswordEncoder.php @@ -11,10 +11,16 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', BasePasswordEncoder::class, CheckPasswordLengthTrait::class)); + +use Symfony\Component\PasswordHasher\Hasher\CheckPasswordLengthTrait; + /** * BasePasswordEncoder is the base class for all password encoders. * * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use CheckPasswordLengthTrait instead */ abstract class BasePasswordEncoder implements PasswordEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/EncoderAwareInterface.php b/src/Symfony/Component/Security/Core/Encoder/EncoderAwareInterface.php index 546f4f7337ab5..70231e2ce3de0 100644 --- a/src/Symfony/Component/Security/Core/Encoder/EncoderAwareInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/EncoderAwareInterface.php @@ -11,8 +11,12 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; + /** * @author Christophe Coevoet + * + * @deprecated since Symfony 5.3, use {@link PasswordHasherAwareInterface} instead. */ interface EncoderAwareInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php b/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php index d07891bf77290..e90498a3dab46 100644 --- a/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php +++ b/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php @@ -11,12 +11,18 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', EncoderFactory::class, PasswordHasherFactory::class)); + use Symfony\Component\Security\Core\Exception\LogicException; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; /** * A generic encoder factory implementation. * * @author Johannes M. Schmitt + * + * @deprecated since Symfony 5.3, use {@link PasswordHasherFactory} instead */ class EncoderFactory implements EncoderFactoryInterface { @@ -34,7 +40,7 @@ public function getEncoder($user) { $encoderKey = null; - if ($user instanceof EncoderAwareInterface && (null !== $encoderName = $user->getEncoderName())) { + if (($user instanceof PasswordHasherAwareInterface && null !== $encoderName = $user->getPasswordHasherName()) || ($user instanceof EncoderAwareInterface && null !== $encoderName = $user->getEncoderName())) { if (!\array_key_exists($encoderName, $this->encoders)) { throw new \RuntimeException(sprintf('The encoder "%s" was not configured.', $encoderName)); } diff --git a/src/Symfony/Component/Security/Core/Encoder/EncoderFactoryInterface.php b/src/Symfony/Component/Security/Core/Encoder/EncoderFactoryInterface.php index 2b9834b6a041c..65fd12d81eb51 100644 --- a/src/Symfony/Component/Security/Core/Encoder/EncoderFactoryInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/EncoderFactoryInterface.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', EncoderFactoryInterface::class, PasswordHasherFactoryInterface::class)); + use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; /** * EncoderFactoryInterface to support different encoders for different accounts. * * @author Johannes M. Schmitt + * + * @deprecated since Symfony 5.3, use {@link PasswordHasherFactoryInterface} instead */ interface EncoderFactoryInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/LegacyEncoderTrait.php b/src/Symfony/Component/Security/Core/Encoder/LegacyEncoderTrait.php new file mode 100644 index 0000000000000..d1263213fe309 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Encoder/LegacyEncoderTrait.php @@ -0,0 +1,56 @@ + + * + * 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; + +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; + +/** + * @internal + */ +trait LegacyEncoderTrait +{ + /** + * @var PasswordHasherInterface|LegacyPasswordHasherInterface + */ + private $hasher; + + /** + * {@inheritdoc} + */ + public function encodePassword(string $raw, ?string $salt): string + { + try { + return $this->hasher->hash($raw, $salt); + } catch (InvalidPasswordException $e) { + throw new BadCredentialsException('Bad credentials.'); + } + } + + /** + * {@inheritdoc} + */ + public function isPasswordValid(string $encoded, string $raw, ?string $salt): bool + { + return $this->hasher->verify($encoded, $raw, $salt); + } + + /** + * {@inheritdoc} + */ + public function needsRehash(string $encoded): bool + { + return $this->hasher->needsRehash($encoded); + } +} diff --git a/src/Symfony/Component/Security/Core/Encoder/MessageDigestPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/MessageDigestPasswordEncoder.php index d769f2f470275..d4b1fb54b3da2 100644 --- a/src/Symfony/Component/Security/Core/Encoder/MessageDigestPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/MessageDigestPasswordEncoder.php @@ -11,19 +11,20 @@ namespace Symfony\Component\Security\Core\Encoder; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', MessageDigestPasswordEncoder::class, MessageDigestPasswordHasher::class)); + +use Symfony\Component\PasswordHasher\Hasher\MessageDigestPasswordHasher; /** * MessageDigestPasswordEncoder uses a message digest algorithm. * * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use {@link MessageDigestPasswordHasher} instead */ class MessageDigestPasswordEncoder extends BasePasswordEncoder { - private $algorithm; - private $encodeHashAsBase64; - private $iterations = 1; - private $encodedLength = -1; + use LegacyEncoderTrait; /** * @param string $algorithm The digest algorithm to use @@ -32,51 +33,6 @@ class MessageDigestPasswordEncoder extends BasePasswordEncoder */ public function __construct(string $algorithm = 'sha512', bool $encodeHashAsBase64 = true, int $iterations = 5000) { - $this->algorithm = $algorithm; - $this->encodeHashAsBase64 = $encodeHashAsBase64; - - try { - $this->encodedLength = \strlen($this->encodePassword('', 'salt')); - } catch (\LogicException $e) { - // ignore algorithm not supported - } - - $this->iterations = $iterations; - } - - /** - * {@inheritdoc} - */ - public function encodePassword(string $raw, ?string $salt) - { - if ($this->isPasswordTooLong($raw)) { - throw new BadCredentialsException('Invalid password.'); - } - - if (!\in_array($this->algorithm, hash_algos(), true)) { - throw new \LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); - } - - $salted = $this->mergePasswordAndSalt($raw, $salt); - $digest = hash($this->algorithm, $salted, true); - - // "stretch" hash - for ($i = 1; $i < $this->iterations; ++$i) { - $digest = hash($this->algorithm, $digest.$salted, true); - } - - return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt) - { - if (\strlen($encoded) !== $this->encodedLength || false !== strpos($encoded, '$')) { - return false; - } - - return !$this->isPasswordTooLong($raw) && $this->comparePasswords($encoded, $this->encodePassword($raw, $salt)); + $this->hasher = new MessageDigestPasswordHasher($algorithm, $encodeHashAsBase64, $iterations); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/MigratingPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/MigratingPasswordEncoder.php index cd10b32bf733f..be178731e1148 100644 --- a/src/Symfony/Component/Security/Core/Encoder/MigratingPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/MigratingPasswordEncoder.php @@ -11,6 +11,10 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', MigratingPasswordEncoder::class, MigratingPasswordHasher::class)); + +use Symfony\Component\PasswordHasher\Hasher\MigratingPasswordHasher; + /** * Hashes passwords using the best available encoder. * Validates them using a chain of encoders. @@ -19,12 +23,11 @@ * could be used to authenticate successfully without knowing the cleartext password. * * @author Nicolas Grekas + * + * @deprecated since Symfony 5.3, use {@link MigratingPasswordHasher} instead */ final class MigratingPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface { - private $bestEncoder; - private $extraEncoders; - public function __construct(PasswordEncoderInterface $bestEncoder, PasswordEncoderInterface ...$extraEncoders) { $this->bestEncoder = $bestEncoder; diff --git a/src/Symfony/Component/Security/Core/Encoder/NativePasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/NativePasswordEncoder.php index 83b7f3f1e89b5..b3bd4b54a285f 100644 --- a/src/Symfony/Component/Security/Core/Encoder/NativePasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/NativePasswordEncoder.php @@ -11,7 +11,10 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', NativePasswordEncoder::class, NativePasswordHasher::class)); + use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; /** * Hashes passwords using password_hash(). @@ -19,105 +22,18 @@ * @author Elnur Abdurrakhimov * @author Terje Bråten * @author Nicolas Grekas + * + * @deprecated since Symfony 5.3, use {@link NativePasswordHasher} instead */ final class NativePasswordEncoder implements PasswordEncoderInterface, SelfSaltingEncoderInterface { - private const MAX_PASSWORD_LENGTH = 4096; - - private $algo = \PASSWORD_BCRYPT; - private $options; + use LegacyEncoderTrait; /** * @param string|null $algo An algorithm supported by password_hash() or null to use the stronger available algorithm */ public function __construct(int $opsLimit = null, int $memLimit = null, int $cost = null, string $algo = null) { - $cost = $cost ?? 13; - $opsLimit = $opsLimit ?? max(4, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); - $memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); - - if (3 > $opsLimit) { - throw new \InvalidArgumentException('$opsLimit must be 3 or greater.'); - } - - if (10 * 1024 > $memLimit) { - throw new \InvalidArgumentException('$memLimit must be 10k or greater.'); - } - - if ($cost < 4 || 31 < $cost) { - throw new \InvalidArgumentException('$cost must be in the range of 4-31.'); - } - - $algos = [1 => \PASSWORD_BCRYPT, '2y' => \PASSWORD_BCRYPT]; - - if (\defined('PASSWORD_ARGON2I')) { - $this->algo = $algos[2] = $algos['argon2i'] = (string) \PASSWORD_ARGON2I; - } - - if (\defined('PASSWORD_ARGON2ID')) { - $this->algo = $algos[3] = $algos['argon2id'] = (string) \PASSWORD_ARGON2ID; - } - - if (null !== $algo) { - $this->algo = $algos[$algo] ?? $algo; - } - - $this->options = [ - 'cost' => $cost, - 'time_cost' => $opsLimit, - 'memory_cost' => $memLimit >> 10, - 'threads' => 1, - ]; - } - - /** - * {@inheritdoc} - */ - public function encodePassword(string $raw, ?string $salt): string - { - if (\strlen($raw) > self::MAX_PASSWORD_LENGTH || ((string) \PASSWORD_BCRYPT === $this->algo && 72 < \strlen($raw))) { - throw new BadCredentialsException('Invalid password.'); - } - - // Ignore $salt, the auto-generated one is always the best - - return password_hash($raw, $this->algo, $this->options); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt): bool - { - if ('' === $raw) { - return false; - } - - if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) { - return false; - } - - if (0 !== strpos($encoded, '$argon')) { - // BCrypt encodes only the first 72 chars - return (72 >= \strlen($raw) || 0 !== strpos($encoded, '$2')) && password_verify($raw, $encoded); - } - - if (\extension_loaded('sodium') && version_compare(\SODIUM_LIBRARY_VERSION, '1.0.14', '>=')) { - return sodium_crypto_pwhash_str_verify($encoded, $raw); - } - - if (\extension_loaded('libsodium') && version_compare(phpversion('libsodium'), '1.0.14', '>=')) { - return \Sodium\crypto_pwhash_str_verify($encoded, $raw); - } - - return password_verify($raw, $encoded); - } - - /** - * {@inheritdoc} - */ - public function needsRehash(string $encoded): bool - { - return password_needs_rehash($encoded, $this->algo, $this->options); + $this->hasher = new NativePasswordHasher($opsLimit, $memLimit, $cost, $algo); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/PasswordEncoderInterface.php b/src/Symfony/Component/Security/Core/Encoder/PasswordEncoderInterface.php index 9d8d48f8db43a..ba9216ebe5160 100644 --- a/src/Symfony/Component/Security/Core/Encoder/PasswordEncoderInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/PasswordEncoderInterface.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', PasswordEncoderInterface::class, PasswordHasherInterface::class)); + use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; /** * PasswordEncoderInterface is the interface for all encoders. * * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use {@link PasswordHasherInterface} instead */ interface PasswordEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/Pbkdf2PasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/Pbkdf2PasswordEncoder.php index ab5e1a5340d84..a50ad01ea1c6b 100644 --- a/src/Symfony/Component/Security/Core/Encoder/Pbkdf2PasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/Pbkdf2PasswordEncoder.php @@ -11,7 +11,10 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', Pbkdf2PasswordEncoder::class, Pbkdf2PasswordHasher::class)); + use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; /** * Pbkdf2PasswordEncoder uses the PBKDF2 (Password-Based Key Derivation Function 2). @@ -25,14 +28,12 @@ * @author Sebastiaan Stok * @author Andrew Johnson * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use {@link Pbkdf2PasswordHasher} instead */ class Pbkdf2PasswordEncoder extends BasePasswordEncoder { - private $algorithm; - private $encodeHashAsBase64; - private $iterations = 1; - private $length; - private $encodedLength = -1; + use LegacyEncoderTrait; /** * @param string $algorithm The digest algorithm to use @@ -42,48 +43,6 @@ class Pbkdf2PasswordEncoder extends BasePasswordEncoder */ public function __construct(string $algorithm = 'sha512', bool $encodeHashAsBase64 = true, int $iterations = 1000, int $length = 40) { - $this->algorithm = $algorithm; - $this->encodeHashAsBase64 = $encodeHashAsBase64; - $this->length = $length; - - try { - $this->encodedLength = \strlen($this->encodePassword('', 'salt')); - } catch (\LogicException $e) { - // ignore algorithm not supported - } - - $this->iterations = $iterations; - } - - /** - * {@inheritdoc} - * - * @throws \LogicException when the algorithm is not supported - */ - public function encodePassword(string $raw, ?string $salt) - { - if ($this->isPasswordTooLong($raw)) { - throw new BadCredentialsException('Invalid password.'); - } - - if (!\in_array($this->algorithm, hash_algos(), true)) { - throw new \LogicException(sprintf('The algorithm "%s" is not supported.', $this->algorithm)); - } - - $digest = hash_pbkdf2($this->algorithm, $raw, $salt, $this->iterations, $this->length, true); - - return $this->encodeHashAsBase64 ? base64_encode($digest) : bin2hex($digest); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt) - { - if (\strlen($encoded) !== $this->encodedLength || false !== strpos($encoded, '$')) { - return false; - } - - return !$this->isPasswordTooLong($raw) && $this->comparePasswords($encoded, $this->encodePassword($raw, $salt)); + $this->hasher = new Pbkdf2PasswordHasher($algorithm, $encodeHashAsBase64, $iterations, $length); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/PlaintextPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/PlaintextPasswordEncoder.php index 90e7e3d5be69e..65fc8502791b5 100644 --- a/src/Symfony/Component/Security/Core/Encoder/PlaintextPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/PlaintextPasswordEncoder.php @@ -11,7 +11,9 @@ namespace Symfony\Component\Security\Core\Encoder; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', PlaintextPasswordEncoder::class, PlaintextPasswordHasher::class)); + +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; /** * PlaintextPasswordEncoder does not do any encoding but is useful in testing environments. @@ -19,46 +21,18 @@ * As this encoder is not cryptographically secure, usage of it in production environments is discouraged. * * @author Fabien Potencier + * + * @deprecated since Symfony 5.3, use {@link PlaintextPasswordHasher} instead */ class PlaintextPasswordEncoder extends BasePasswordEncoder { - private $ignorePasswordCase; + use LegacyEncoderTrait; /** * @param bool $ignorePasswordCase Compare password case-insensitive */ public function __construct(bool $ignorePasswordCase = false) { - $this->ignorePasswordCase = $ignorePasswordCase; - } - - /** - * {@inheritdoc} - */ - public function encodePassword(string $raw, ?string $salt) - { - if ($this->isPasswordTooLong($raw)) { - throw new BadCredentialsException('Invalid password.'); - } - - return $this->mergePasswordAndSalt($raw, $salt); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt) - { - if ($this->isPasswordTooLong($raw)) { - return false; - } - - $pass2 = $this->mergePasswordAndSalt($raw, $salt); - - if (!$this->ignorePasswordCase) { - return $this->comparePasswords($encoded, $pass2); - } - - return $this->comparePasswords(strtolower($encoded), strtolower($pass2)); + $this->hasher = new PlaintextPasswordHasher($ignorePasswordCase); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/SelfSaltingEncoderInterface.php b/src/Symfony/Component/Security/Core/Encoder/SelfSaltingEncoderInterface.php index 37855b60cff83..6bb983dd14e69 100644 --- a/src/Symfony/Component/Security/Core/Encoder/SelfSaltingEncoderInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/SelfSaltingEncoderInterface.php @@ -11,11 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" interface is deprecated, use "%s" on hasher implementations that deal with salts instead.', SelfSaltingEncoderInterface::class, LegacyPasswordHasherInterface::class)); + +use Symfony\Component\PasswordHasher\LegacyPasswordHasherInterface; + /** * SelfSaltingEncoderInterface is a marker interface for encoders that do not * require a user-generated salt. * * @author Zan Baldwin + * + * @deprecated since Symfony 5.3, use {@link LegacyPasswordHasherInterface} instead */ interface SelfSaltingEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/SodiumPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/SodiumPasswordEncoder.php index 53c6660014a78..480adb4a14f34 100644 --- a/src/Symfony/Component/Security/Core/Encoder/SodiumPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/SodiumPasswordEncoder.php @@ -11,8 +11,9 @@ namespace Symfony\Component\Security\Core\Encoder; -use Symfony\Component\Security\Core\Exception\BadCredentialsException; -use Symfony\Component\Security\Core\Exception\LogicException; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', SodiumPasswordEncoder::class, SodiumPasswordHasher::class)); + +use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher; /** * Hashes passwords using libsodium. @@ -20,99 +21,20 @@ * @author Robin Chalas * @author Zan Baldwin * @author Dominik Müller + * + * @deprecated since Symfony 5.3, use {@link SodiumPasswordHasher} instead */ final class SodiumPasswordEncoder implements PasswordEncoderInterface, SelfSaltingEncoderInterface { - private const MAX_PASSWORD_LENGTH = 4096; - - private $opsLimit; - private $memLimit; + use LegacyEncoderTrait; public function __construct(int $opsLimit = null, int $memLimit = null) { - if (!self::isSupported()) { - throw new LogicException('Libsodium is not available. You should either install the sodium extension, upgrade to PHP 7.2+ or use a different encoder.'); - } - - $this->opsLimit = $opsLimit ?? max(4, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); - $this->memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); - - if (3 > $this->opsLimit) { - throw new \InvalidArgumentException('$opsLimit must be 3 or greater.'); - } - - if (10 * 1024 > $this->memLimit) { - throw new \InvalidArgumentException('$memLimit must be 10k or greater.'); - } + $this->hasher = new SodiumPasswordHasher($opsLimit, $memLimit); } public static function isSupported(): bool { - return version_compare(\extension_loaded('sodium') ? \SODIUM_LIBRARY_VERSION : phpversion('libsodium'), '1.0.14', '>='); - } - - /** - * {@inheritdoc} - */ - public function encodePassword(string $raw, ?string $salt): string - { - if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) { - throw new BadCredentialsException('Invalid password.'); - } - - if (\function_exists('sodium_crypto_pwhash_str')) { - return sodium_crypto_pwhash_str($raw, $this->opsLimit, $this->memLimit); - } - - if (\extension_loaded('libsodium')) { - return \Sodium\crypto_pwhash_str($raw, $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.'); - } - - /** - * {@inheritdoc} - */ - public function isPasswordValid(string $encoded, string $raw, ?string $salt): bool - { - if ('' === $raw) { - return false; - } - - if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) { - return false; - } - - if (0 !== strpos($encoded, '$argon')) { - // Accept validating non-argon passwords for seamless migrations - return (72 >= \strlen($raw) || 0 !== strpos($encoded, '$2')) && password_verify($raw, $encoded); - } - - if (\function_exists('sodium_crypto_pwhash_str_verify')) { - return sodium_crypto_pwhash_str_verify($encoded, $raw); - } - - if (\extension_loaded('libsodium')) { - return \Sodium\crypto_pwhash_str_verify($encoded, $raw); - } - - return false; - } - - /** - * {@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.'); + return SodiumPasswordHasher::isSupported(); } } diff --git a/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoder.php index aeb29956469d7..bfe31a4a0faa2 100644 --- a/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoder.php +++ b/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoder.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" class is deprecated, use "%s" instead.', UserPasswordEncoder::class, UserPasswordHasher::class)); + use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; /** * A generic password encoder. * * @author Ariel Ferrandini + * + * @deprecated since Symfony 5.3, use {@link UserPasswordHasher} instead */ class UserPasswordEncoder implements UserPasswordEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoderInterface.php b/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoderInterface.php index 522ec0b02300c..858e83676835e 100644 --- a/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoderInterface.php +++ b/src/Symfony/Component/Security/Core/Encoder/UserPasswordEncoderInterface.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Security\Core\Encoder; +trigger_deprecation('symfony/security-core', '5.3', sprintf('The "%s" interface is deprecated, use "%s" on hasher implementations that deal with salts instead.', UserPasswordEncoderInterface::class, UserPasswordHasherInterface::class)); + use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; /** * UserPasswordEncoderInterface is the interface for the password encoder service. * * @author Ariel Ferrandini + * + * @deprecated since Symfony 5.3, use {@link UserPasswordHasherInterface} instead */ interface UserPasswordEncoderInterface { diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/DaoAuthenticationProviderTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/DaoAuthenticationProviderTest.php index 57ed2d0bf786f..20e75b80760df 100644 --- a/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/DaoAuthenticationProviderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/DaoAuthenticationProviderTest.php @@ -15,17 +15,17 @@ use Symfony\Component\Security\Core\Authentication\Provider\DaoAuthenticationProvider; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; -use Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder; 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\Tests\Encoder\TestPasswordEncoderInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; class DaoAuthenticationProviderTest extends TestCase { @@ -39,7 +39,10 @@ public function testRetrieveUserWhenProviderDoesNotReturnAnUserInterface() $method->invoke($provider, 'fabien', $this->getSupportedToken()); } - public function testRetrieveUserWhenUsernameIsNotFound() + /** + * @group legacy + */ + public function testRetrieveUserWhenUsernameIsNotFoundWithLegacyEncoderFactory() { $this->expectException(UsernameNotFoundException::class); $userProvider = $this->createMock(UserProviderInterface::class); @@ -55,6 +58,22 @@ public function testRetrieveUserWhenUsernameIsNotFound() $method->invoke($provider, 'fabien', $this->getSupportedToken()); } + public function testRetrieveUserWhenUsernameIsNotFound() + { + $this->expectException(UsernameNotFoundException::class); + $userProvider = $this->createMock(UserProviderInterface::class); + $userProvider->expects($this->once()) + ->method('loadUserByUsername') + ->willThrowException(new UsernameNotFoundException()) + ; + + $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(PasswordHasherFactoryInterface::class)); + $method = new \ReflectionMethod($provider, 'retrieveUser'); + $method->setAccessible(true); + + $method->invoke($provider, 'fabien', $this->getSupportedToken()); + } + public function testRetrieveUserWhenAnExceptionOccurs() { $this->expectException(AuthenticationServiceException::class); @@ -64,7 +83,7 @@ public function testRetrieveUserWhenAnExceptionOccurs() ->willThrowException(new \RuntimeException()) ; - $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(EncoderFactoryInterface::class)); + $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(PasswordHasherFactoryInterface::class)); $method = new \ReflectionMethod($provider, 'retrieveUser'); $method->setAccessible(true); @@ -85,7 +104,7 @@ public function testRetrieveUserReturnsUserFromTokenOnReauthentication() ->willReturn($user) ; - $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(EncoderFactoryInterface::class)); + $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(PasswordHasherFactoryInterface::class)); $reflection = new \ReflectionMethod($provider, 'retrieveUser'); $reflection->setAccessible(true); $result = $reflection->invoke($provider, 'someUser', $token); @@ -103,7 +122,7 @@ public function testRetrieveUser() ->willReturn($user) ; - $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(EncoderFactoryInterface::class)); + $provider = new DaoAuthenticationProvider($userProvider, $this->createMock(UserCheckerInterface::class), 'key', $this->createMock(PasswordHasherFactoryInterface::class)); $method = new \ReflectionMethod($provider, 'retrieveUser'); $method->setAccessible(true); @@ -113,13 +132,13 @@ public function testRetrieveUser() public function testCheckAuthenticationWhenCredentialsAreEmpty() { $this->expectException(BadCredentialsException::class); - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder + $hasher = $this->getMockBuilder(PasswordHasherInterface::class)->getMock(); + $hasher ->expects($this->never()) - ->method('isPasswordValid') + ->method('verify') ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $method = new \ReflectionMethod($provider, 'checkAuthentication'); $method->setAccessible(true); @@ -135,14 +154,14 @@ public function testCheckAuthenticationWhenCredentialsAreEmpty() public function testCheckAuthenticationWhenCredentialsAre0() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher ->expects($this->once()) - ->method('isPasswordValid') + ->method('verify') ->willReturn(true) ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $method = new \ReflectionMethod($provider, 'checkAuthentication'); $method->setAccessible(true); @@ -163,13 +182,13 @@ public function testCheckAuthenticationWhenCredentialsAre0() public function testCheckAuthenticationWhenCredentialsAreNotValid() { $this->expectException(BadCredentialsException::class); - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->once()) - ->method('isPasswordValid') + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->once()) + ->method('verify') ->willReturn(false) ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $method = new \ReflectionMethod($provider, 'checkAuthentication'); $method->setAccessible(true); @@ -235,13 +254,13 @@ public function testCheckAuthenticationWhenTokenNeedsReauthenticationWorksWithou public function testCheckAuthentication() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->once()) - ->method('isPasswordValid') + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->once()) + ->method('verify') ->willReturn(true) ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $method = new \ReflectionMethod($provider, 'checkAuthentication'); $method->setAccessible(true); @@ -258,21 +277,21 @@ public function testPasswordUpgrades() { $user = new User('user', 'pwd'); - $encoder = $this->createMock(TestPasswordEncoderInterface::class); - $encoder->expects($this->once()) - ->method('isPasswordValid') + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->once()) + ->method('verify') ->willReturn(true) ; - $encoder->expects($this->once()) - ->method('encodePassword') + $hasher->expects($this->once()) + ->method('hash') ->willReturn('foobar') ; - $encoder->expects($this->once()) + $hasher->expects($this->once()) ->method('needsRehash') ->willReturn(true) ; - $provider = $this->getProvider(null, null, $encoder); + $provider = $this->getProvider(null, null, $hasher); $userProvider = ((array) $provider)[sprintf("\0%s\0userProvider", DaoAuthenticationProvider::class)]; $userProvider->expects($this->once()) @@ -304,7 +323,7 @@ protected function getSupportedToken() return $mock; } - protected function getProvider($user = null, $userChecker = null, $passwordEncoder = null) + protected function getProvider($user = null, $userChecker = null, $passwordHasher = null) { $userProvider = $this->createMock(PasswordUpgraderProvider::class); if (null !== $user) { @@ -318,18 +337,18 @@ protected function getProvider($user = null, $userChecker = null, $passwordEncod $userChecker = $this->createMock(UserCheckerInterface::class); } - if (null === $passwordEncoder) { - $passwordEncoder = new PlaintextPasswordEncoder(); + if (null === $passwordHasher) { + $passwordHasher = new PlaintextPasswordHasher(); } - $encoderFactory = $this->createMock(EncoderFactoryInterface::class); - $encoderFactory + $hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $hasherFactory ->expects($this->any()) - ->method('getEncoder') - ->willReturn($passwordEncoder) + ->method('getPasswordHasher') + ->willReturn($passwordHasher) ; - return new DaoAuthenticationProvider($userProvider, $userChecker, 'key', $encoderFactory); + return new DaoAuthenticationProvider($userProvider, $userChecker, 'key', $hasherFactory); } } diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/EncoderFactoryTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/EncoderFactoryTest.php index a6999991393c4..7b79986b826a6 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/EncoderFactoryTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/EncoderFactoryTest.php @@ -20,7 +20,13 @@ use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\MessageDigestPasswordHasher; +/** + * @group legacy + */ class EncoderFactoryTest extends TestCase { public function testGetEncoderWithMessageDigestEncoder() @@ -176,6 +182,17 @@ public function testDefaultMigratingEncoders() (new EncoderFactory([SomeUser::class => ['class' => SodiumPasswordEncoder::class, 'arguments' => []]]))->getEncoder(SomeUser::class) ); } + + public function testHasherAwareCompat() + { + $factory = new PasswordHasherFactory([ + 'encoder_name' => new MessageDigestPasswordHasher('sha1'), + ]); + + $encoder = $factory->getPasswordHasher(new HasherAwareUser('user', 'pass')); + $expectedEncoder = new MessageDigestPasswordHasher('sha1'); + $this->assertEquals($expectedEncoder->hash('foo', ''), $encoder->hash('foo', '')); + } } class SomeUser implements UserInterface @@ -214,3 +231,14 @@ public function getEncoderName(): ?string return $this->encoderName; } } + + +class HasherAwareUser extends SomeUser implements PasswordHasherAwareInterface +{ + public $hasherName = 'encoder_name'; + + public function getPasswordHasherName(): ?string + { + return $this->hasherName; + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/MessageDigestPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/MessageDigestPasswordEncoderTest.php index c2b514bb6b0af..a354b0dbf25a8 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/MessageDigestPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/MessageDigestPasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\MessageDigestPasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +/** + * @group legacy + */ class MessageDigestPasswordEncoderTest extends TestCase { public function testIsPasswordValid() diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/MigratingPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/MigratingPasswordEncoderTest.php index efa360ecb2cf1..fbaf89b0b1b1a 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/MigratingPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/MigratingPasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\MigratingPasswordEncoder; use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; +/** + * @group legacy + */ class MigratingPasswordEncoderTest extends TestCase { public function testValidation() diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/NativePasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/NativePasswordEncoderTest.php index c67bf8668b4dd..9d864dfce038e 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/NativePasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/NativePasswordEncoderTest.php @@ -16,6 +16,7 @@ /** * @author Elnur Abdurrakhimov + * @group legacy */ class NativePasswordEncoderTest extends TestCase { diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/Pbkdf2PasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/Pbkdf2PasswordEncoderTest.php index db274716bd834..000e07d659113 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/Pbkdf2PasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/Pbkdf2PasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +/** + * @group legacy + */ class Pbkdf2PasswordEncoderTest extends TestCase { public function testIsPasswordValid() diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/PlaintextPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/PlaintextPasswordEncoderTest.php index fb5e674567d1b..398044035eb61 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/PlaintextPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/PlaintextPasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +/** + * @group legacy + */ class PlaintextPasswordEncoderTest extends TestCase { public function testIsPasswordValid() diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/SodiumPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/SodiumPasswordEncoderTest.php index b4073a1cfba53..4bae5f89f35bc 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/SodiumPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/SodiumPasswordEncoderTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; +/** + * @group legacy + */ class SodiumPasswordEncoderTest extends TestCase { protected function setUp(): void diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/TestPasswordEncoderInterface.php b/src/Symfony/Component/Security/Core/Tests/Encoder/TestPasswordEncoderInterface.php index 13e2d0d3b36ea..3764038e9a9d3 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/TestPasswordEncoderInterface.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/TestPasswordEncoderInterface.php @@ -13,6 +13,9 @@ use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; +/** + * @group legacy + */ interface TestPasswordEncoderInterface extends PasswordEncoderInterface { public function needsRehash(string $encoded): bool; diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/UserPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/UserPasswordEncoderTest.php index 0d72919abc40a..6f52fbf1b22d9 100644 --- a/src/Symfony/Component/Security/Core/Tests/Encoder/UserPasswordEncoderTest.php +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/UserPasswordEncoderTest.php @@ -19,6 +19,9 @@ use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Core\User\UserInterface; +/** + * @group legacy + */ class UserPasswordEncoderTest extends TestCase { public function testEncodePassword() diff --git a/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php b/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php index 9c65298b07f56..ef62023d53e45 100644 --- a/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php +++ b/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php @@ -17,11 +17,11 @@ interface PasswordUpgraderInterface { /** - * Upgrades the encoded password of a user, typically for using a better hash algorithm. + * Upgrades the hashed password of a user, typically for using a better hash algorithm. * * This method should persist the new password in the user storage and update the $user object accordingly. * Because you don't want your users not being able to log in, this method should be opportunistic: * it's fine if it does nothing or if it fails without throwing any exception. */ - public function upgradePassword(UserInterface $user, string $newEncodedPassword): void; + public function upgradePassword(UserInterface $user, string $newHashedPassword): void; } diff --git a/src/Symfony/Component/Security/Core/User/UserInterface.php b/src/Symfony/Component/Security/Core/User/UserInterface.php index 239eb0ed00c6c..c005e3ca9c9b7 100644 --- a/src/Symfony/Component/Security/Core/User/UserInterface.php +++ b/src/Symfony/Component/Security/Core/User/UserInterface.php @@ -15,7 +15,7 @@ * Represents the interface that all user classes must implement. * * This interface is useful because the authentication layer can deal with - * the object through its lifecycle, using the object to get the encoded + * the object through its lifecycle, using the object to get the hashed * password (for checking against a submitted password), assigning roles * and so on. * @@ -49,17 +49,17 @@ public function getRoles(); /** * Returns the password used to authenticate the user. * - * This should be the encoded password. On authentication, a plain-text - * password will be salted, encoded, and then compared to this value. + * This should be the hashed password. On authentication, a plain-text + * password will be hashed, and then compared to this value. * - * @return string|null The encoded password if any + * @return string|null The hashed password if any */ public function getPassword(); /** - * Returns the salt that was originally used to encode the password. + * Returns the salt that was originally used to hash the password. * - * This can return null if the password was not encoded using a salt. + * This can return null if the password was not hashed using a salt. * * @return string|null The salt */ diff --git a/src/Symfony/Component/Security/Core/Validator/Constraints/UserPasswordValidator.php b/src/Symfony/Component/Security/Core/Validator/Constraints/UserPasswordValidator.php index 24b032484fa1a..0181ccbcbb935 100644 --- a/src/Symfony/Component/Security/Core/Validator/Constraints/UserPasswordValidator.php +++ b/src/Symfony/Component/Security/Core/Validator/Constraints/UserPasswordValidator.php @@ -13,7 +13,9 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; @@ -22,12 +24,19 @@ class UserPasswordValidator extends ConstraintValidator { private $tokenStorage; - private $encoderFactory; + private $hasherFactory; - public function __construct(TokenStorageInterface $tokenStorage, EncoderFactoryInterface $encoderFactory) + /** + * @param PasswordHasherFactoryInterface $hasherFactory + */ + public function __construct(TokenStorageInterface $tokenStorage, $hasherFactory) { + if ($hasherFactory instanceof EncoderFactoryInterface) { + trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); + } + $this->tokenStorage = $tokenStorage; - $this->encoderFactory = $encoderFactory; + $this->hasherFactory = $hasherFactory; } /** @@ -51,9 +60,9 @@ public function validate($password, Constraint $constraint) throw new ConstraintDefinitionException('The User object must implement the UserInterface interface.'); } - $encoder = $this->encoderFactory->getEncoder($user); + $hasher = $this->hasherFactory instanceof EncoderFactoryInterface ? $this->hasherFactory->getEncoder($user) : $this->hasherFactory->getPasswordHasher($user); - if (null === $user->getPassword() || !$encoder->isPasswordValid($user->getPassword(), $password, $user->getSalt())) { + if (null === $user->getPassword() || !($hasher instanceof PasswordEncoderInterface ? $hasher->isPasswordValid($user->getPassword(), $password, $user->getSalt()) : $hasher->verify($user->getPassword(), $password, $user->getSalt()))) { $this->context->addViolation($constraint->message); } } diff --git a/src/Symfony/Component/Security/Core/composer.json b/src/Symfony/Component/Security/Core/composer.json index 48a6a46ec4e77..424c077569d51 100644 --- a/src/Symfony/Component/Security/Core/composer.json +++ b/src/Symfony/Component/Security/Core/composer.json @@ -20,7 +20,8 @@ "symfony/event-dispatcher-contracts": "^1.1|^2", "symfony/polyfill-php80": "^1.15", "symfony/service-contracts": "^1.1.6|^2", - "symfony/deprecation-contracts": "^2.1" + "symfony/deprecation-contracts": "^2.1", + "symfony/password-hasher": "^5.3" }, "require-dev": { "psr/container": "^1.0", diff --git a/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php b/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php index 49244680ad737..4d2ec108999a3 100644 --- a/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php +++ b/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AuthenticationExpiredException; @@ -26,6 +27,7 @@ use Symfony\Component\Security\Guard\PasswordAuthenticatedInterface; use Symfony\Component\Security\Guard\Token\GuardTokenInterface; use Symfony\Component\Security\Guard\Token\PreAuthenticationGuardToken; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; /** * Responsible for accepting the PreAuthenticationGuardToken and calling @@ -42,19 +44,24 @@ class GuardAuthenticationProvider implements AuthenticationProviderInterface private $userProvider; private $providerKey; private $userChecker; - private $passwordEncoder; + private $passwordHasher; /** * @param iterable|AuthenticatorInterface[] $guardAuthenticators The authenticators, with keys that match what's passed to GuardAuthenticationListener * @param string $providerKey The provider (i.e. firewall) key + * @param UserPasswordHasherInterface $passwordHasher */ - public function __construct(iterable $guardAuthenticators, UserProviderInterface $userProvider, string $providerKey, UserCheckerInterface $userChecker, UserPasswordEncoderInterface $passwordEncoder = null) + public function __construct(iterable $guardAuthenticators, UserProviderInterface $userProvider, string $providerKey, UserCheckerInterface $userChecker, $passwordHasher = null) { $this->guardAuthenticators = $guardAuthenticators; $this->userProvider = $userProvider; $this->providerKey = $providerKey; $this->userChecker = $userChecker; - $this->passwordEncoder = $passwordEncoder; + $this->passwordHasher = $passwordHasher; + + if ($passwordHasher instanceof UserPasswordEncoderInterface) { + trigger_deprecation('symfony/security-core', '5.3', sprintf('Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', UserPasswordEncoderInterface::class, __CLASS__, UserPasswordHasherInterface::class)); + } } /** @@ -123,8 +130,13 @@ private function authenticateViaGuard(AuthenticatorInterface $guardAuthenticator throw new BadCredentialsException(sprintf('Authentication failed because "%s::checkCredentials()" did not return true.', get_debug_type($guardAuthenticator))); } - if ($this->userProvider instanceof PasswordUpgraderInterface && $guardAuthenticator instanceof PasswordAuthenticatedInterface && null !== $this->passwordEncoder && (null !== $password = $guardAuthenticator->getPassword($token->getCredentials())) && method_exists($this->passwordEncoder, 'needsRehash') && $this->passwordEncoder->needsRehash($user)) { - $this->userProvider->upgradePassword($user, $this->passwordEncoder->encodePassword($user, $password)); + if ($this->userProvider instanceof PasswordUpgraderInterface && $guardAuthenticator instanceof PasswordAuthenticatedInterface && null !== $this->passwordHasher && (null !== $password = $guardAuthenticator->getPassword($token->getCredentials())) && method_exists($this->passwordHasher, 'needsRehash') && $this->passwordHasher->needsRehash($user)) { + if ($this->passwordHasher instanceof PasswordEncoderInterface) { + // @deprecated since Symfony 5.3 + $this->userProvider->upgradePassword($user, $this->passwordHasher->encodePassword($user, $password)); + } else { + $this->userProvider->upgradePassword($user, $this->passwordHasher->hashPassword($user, $password)); + } } $this->userChecker->checkPostAuth($user); diff --git a/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php b/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php index bd0d85fd7a2eb..95f9c88542189 100644 --- a/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php @@ -19,6 +19,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; /** * This listeners uses the interfaces of authenticators to @@ -31,18 +32,25 @@ */ class CheckCredentialsListener implements EventSubscriberInterface { - private $encoderFactory; + private $hasherFactory; - public function __construct(EncoderFactoryInterface $encoderFactory) + /** + * @param PasswordHasherFactoryInterface $hasherFactory + */ + public function __construct($hasherFactory) { - $this->encoderFactory = $encoderFactory; + if ($hasherFactory instanceof EncoderFactoryInterface) { + trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); + } + + $this->hasherFactory = $hasherFactory; } public function checkPassport(CheckPassportEvent $event): void { $passport = $event->getPassport(); if ($passport instanceof UserPassportInterface && $passport->hasBadge(PasswordCredentials::class)) { - // Use the password encoder to validate the credentials + // Use the password hasher to validate the credentials $user = $passport->getUser(); /** @var PasswordCredentials $badge */ $badge = $passport->getBadge(PasswordCredentials::class); @@ -60,8 +68,15 @@ public function checkPassport(CheckPassportEvent $event): void throw new BadCredentialsException('The presented password is invalid.'); } - if (!$this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) { - throw new BadCredentialsException('The presented password is invalid.'); + // @deprecated since Symfony 5.3 + if ($this->hasherFactory instanceof EncoderFactoryInterface) { + if (!$this->hasherFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) { + throw new BadCredentialsException('The presented password is invalid.'); + } + } else { + if (!$this->hasherFactory->getPasswordHasher($user)->verify($user->getPassword(), $presentedPassword, $user->getSalt())) { + throw new BadCredentialsException('The presented password is invalid.'); + } } $badge->markResolved(); diff --git a/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php b/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php index fb1f229d6f106..a3755ca3ab405 100644 --- a/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/PasswordMigratingListener.php @@ -12,12 +12,15 @@ namespace Symfony\Component\Security\Http\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\PasswordHasher\Exception\InvalidPasswordException; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; /** * @author Wouter de Jong @@ -27,11 +30,18 @@ */ class PasswordMigratingListener implements EventSubscriberInterface { - private $encoderFactory; + private $hasherFactory; - public function __construct(EncoderFactoryInterface $encoderFactory) + /** + * @param PasswordHasherFactoryInterface $hasherFactory + */ + public function __construct($hasherFactory) { - $this->encoderFactory = $encoderFactory; + if ($hasherFactory instanceof EncoderFactoryInterface) { + trigger_deprecation('symfony/security-core', '5.3', 'Passing a "%s" instance to the "%s" constructor is deprecated, use "%s" instead.', EncoderFactoryInterface::class, __CLASS__, PasswordHasherFactoryInterface::class); + } + + $this->hasherFactory = $hasherFactory; } public function onLoginSuccess(LoginSuccessEvent $event): void @@ -50,8 +60,8 @@ public function onLoginSuccess(LoginSuccessEvent $event): void } $user = $passport->getUser(); - $passwordEncoder = $this->encoderFactory->getEncoder($user); - if (!$passwordEncoder->needsRehash($user->getPassword())) { + $passwordHasher = $this->hasherFactory instanceof EncoderFactoryInterface ? $this->hasherFactory->getEncoder($user) : $this->hasherFactory->getPasswordHasher($user); + if (!$passwordHasher->needsRehash($user->getPassword())) { return; } @@ -72,7 +82,7 @@ public function onLoginSuccess(LoginSuccessEvent $event): void } } - $passwordUpgrader->upgradePassword($user, $passwordEncoder->encodePassword($plaintextPassword, $user->getSalt())); + $passwordUpgrader->upgradePassword($user, $passwordHasher->hash($plaintextPassword, $user->getSalt())); } public static function getSubscribedEvents(): array diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php index 79e914965ab9e..27e20917d0bc6 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/HttpBasicAuthenticatorTest.php @@ -4,31 +4,31 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Http\Authenticator\HttpBasicAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Tests\Authenticator\Fixtures\PasswordUpgraderProvider; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; class HttpBasicAuthenticatorTest extends TestCase { private $userProvider; - private $encoderFactory; - private $encoder; + private $hasherFactory; + private $hasher; private $authenticator; protected function setUp(): void { $this->userProvider = $this->createMock(UserProviderInterface::class); - $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class); - $this->encoder = $this->createMock(PasswordEncoderInterface::class); - $this->encoderFactory + $this->hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $this->hasher = $this->createMock(PasswordHasherInterface::class); + $this->hasherFactory ->expects($this->any()) - ->method('getEncoder') - ->willReturn($this->encoder); + ->method('getPasswordHasher') + ->willReturn($this->hasher); $this->authenticator = new HttpBasicAuthenticator('test', $this->userProvider); } diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/CheckCredentialsListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/CheckCredentialsListenerTest.php index e903dcd22cbf6..315d7ccce4585 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/CheckCredentialsListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/CheckCredentialsListenerTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\User\User; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; @@ -25,18 +24,20 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Event\CheckPassportEvent; use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; class CheckCredentialsListenerTest extends TestCase { - private $encoderFactory; + private $hasherFactory; private $listener; private $user; protected function setUp(): void { - $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class); - $this->listener = new CheckCredentialsListener($this->encoderFactory); - $this->user = new User('wouter', 'encoded-password'); + $this->hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $this->listener = new CheckCredentialsListener($this->hasherFactory); + $this->user = new User('wouter', 'password-hash'); } /** @@ -44,10 +45,10 @@ protected function setUp(): void */ public function testPasswordAuthenticated($password, $passwordValid, $result) { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', $password)->willReturn($passwordValid); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->any())->method('verify')->with('password-hash', $password)->willReturn($passwordValid); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); if (false === $result) { $this->expectException(BadCredentialsException::class); @@ -73,7 +74,7 @@ public function testEmptyPassword() $this->expectException(BadCredentialsException::class); $this->expectExceptionMessage('The presented password cannot be empty.'); - $this->encoderFactory->expects($this->never())->method('getEncoder'); + $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); $event = $this->createEvent(new Passport(new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials(''))); $this->listener->checkPassport($event); @@ -84,7 +85,7 @@ public function testEmptyPassword() */ public function testCustomAuthenticated($result) { - $this->encoderFactory->expects($this->never())->method('getEncoder'); + $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); if (false === $result) { $this->expectException(BadCredentialsException::class); @@ -108,7 +109,7 @@ public function provideCustomAuthenticatedResults() public function testNoCredentialsBadgeProvided() { - $this->encoderFactory->expects($this->never())->method('getEncoder'); + $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('wouter', function () { return $this->user; }))); $this->listener->checkPassport($event); @@ -116,10 +117,10 @@ public function testNoCredentialsBadgeProvided() public function testAddsPasswordUpgradeBadge() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', 'ThePa$$word')->willReturn(true); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->any())->method('verify')->with('password-hash', 'ThePa$$word')->willReturn(true); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); $passport = new Passport(new UserBadge('wouter', function () { return $this->user; }), new PasswordCredentials('ThePa$$word')); $this->listener->checkPassport($this->createEvent($passport)); @@ -130,10 +131,10 @@ public function testAddsPasswordUpgradeBadge() public function testAddsNoPasswordUpgradeBadgeIfItAlreadyExists() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', 'ThePa$$word')->willReturn(true); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->any())->method('verify')->with('password-hash', 'ThePa$$word')->willReturn(true); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); $passport = $this->getMockBuilder(Passport::class) ->setMethods(['addBadge']) @@ -147,10 +148,10 @@ public function testAddsNoPasswordUpgradeBadgeIfItAlreadyExists() public function testAddsNoPasswordUpgradeBadgeIfPasswordIsInvalid() { - $encoder = $this->createMock(PasswordEncoderInterface::class); - $encoder->expects($this->any())->method('isPasswordValid')->with('encoded-password', 'ThePa$$word')->willReturn(false); + $hasher = $this->createMock(PasswordHasherInterface::class); + $hasher->expects($this->any())->method('verify')->with('password-hash', 'ThePa$$word')->willReturn(false); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->identicalTo($this->user))->willReturn($encoder); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->identicalTo($this->user))->willReturn($hasher); $passport = $this->getMockBuilder(Passport::class) ->setMethods(['addBadge']) diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php index 39a62e0f69d68..0e32433c5b251 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/PasswordMigratingListenerTest.php @@ -14,8 +14,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; @@ -27,23 +25,25 @@ use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; class PasswordMigratingListenerTest extends TestCase { - private $encoderFactory; + private $hasherFactory; private $listener; private $user; protected function setUp(): void { $this->user = $this->createMock(UserInterface::class); - $this->user->expects($this->any())->method('getPassword')->willReturn('old-encoded-password'); - $encoder = $this->createMock(PasswordEncoderInterface::class); + $this->user->expects($this->any())->method('getPassword')->willReturn('old-hash'); + $encoder = $this->createMock(PasswordHasherInterface::class); $encoder->expects($this->any())->method('needsRehash')->willReturn(true); - $encoder->expects($this->any())->method('encodePassword')->with('pa$$word', null)->willReturn('new-encoded-password'); - $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class); - $this->encoderFactory->expects($this->any())->method('getEncoder')->with($this->user)->willReturn($encoder); - $this->listener = new PasswordMigratingListener($this->encoderFactory); + $encoder->expects($this->any())->method('hash')->with('pa$$word', null)->willReturn('new-hash'); + $this->hasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $this->hasherFactory->expects($this->any())->method('getPasswordHasher')->with($this->user)->willReturn($encoder); + $this->listener = new PasswordMigratingListener($this->hasherFactory); } /** @@ -51,7 +51,7 @@ protected function setUp(): void */ public function testUnsupportedEvents($event) { - $this->encoderFactory->expects($this->never())->method('getEncoder'); + $this->hasherFactory->expects($this->never())->method('getPasswordHasher'); $this->listener->onLoginSuccess($event); } @@ -87,7 +87,7 @@ public function testUpgradeWithUpgrader() $passwordUpgrader = $this->createPasswordUpgrader(); $passwordUpgrader->expects($this->once()) ->method('upgradePassword') - ->with($this->user, 'new-encoded-password') + ->with($this->user, 'new-hash') ; $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', function () { return $this->user; }), [new PasswordUpgradeBadge('pa$$word', $passwordUpgrader)])); @@ -101,7 +101,7 @@ public function testUpgradeWithoutUpgrader() $userLoader->expects($this->once()) ->method('upgradePassword') - ->with($this->user, 'new-encoded-password') + ->with($this->user, 'new-hash') ; $event = $this->createEvent(new SelfValidatingPassport(new UserBadge('test', [$userLoader, 'loadUserByUsername']), [new PasswordUpgradeBadge('pa$$word')])); diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index f4d31854a87fb..c2297fc0535b4 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.2.5", "symfony/deprecation-contracts": "^2.1", - "symfony/security-core": "^5.2", + "symfony/security-core": "^5.3", "symfony/http-foundation": "^5.2", "symfony/http-kernel": "^5.2", "symfony/polyfill-php80": "^1.15",