From c5c981c5593c79f080bd2e9e17577c596d738ce0 Mon Sep 17 00:00:00 2001 From: Robin Chalas Date: Tue, 5 Jan 2021 01:14:26 +0100 Subject: [PATCH] [Security] Extract password hashing from security-core - using the right naming --- UPGRADE-5.3.md | 11 + UPGRADE-6.0.md | 11 + composer.json | 1 + .../Bundle/FrameworkBundle/composer.json | 2 +- .../Bundle/SecurityBundle/CHANGELOG.md | 10 + .../Command/UserPasswordEncoderCommand.php | 5 + .../DependencyInjection/MainConfiguration.php | 69 ++++ .../DependencyInjection/SecurityExtension.php | 141 ++++++- .../Resources/config/console.php | 13 +- .../SecurityBundle/Resources/config/guard.php | 2 +- .../Resources/config/password_hasher.php | 30 ++ .../Resources/config/schema/security-1.0.xsd | 25 ++ .../Resources/config/security.php | 13 +- .../config/security_authenticator.php | 4 +- .../Resources/config/security_listeners.php | 2 +- .../CompleteConfigurationTest.php | 298 +++++++++++++- .../Fixtures/php/argon2i_encoder.php | 2 +- .../Fixtures/php/argon2i_hasher.php | 13 + .../Fixtures/php/bcrypt_encoder.php | 2 +- .../Fixtures/php/bcrypt_hasher.php | 12 + .../Fixtures/php/container1.php | 4 +- .../Fixtures/php/legacy_encoders.php | 108 ++++++ .../Fixtures/php/migrating_encoder.php | 2 +- .../Fixtures/php/migrating_hasher.php | 14 + .../Fixtures/php/sodium_encoder.php | 2 +- .../Fixtures/php/sodium_hasher.php | 13 + .../Fixtures/xml/argon2i_encoder.xml | 2 +- .../Fixtures/xml/argon2i_hasher.xml | 19 + .../Fixtures/xml/bcrypt_encoder.xml | 2 +- .../Fixtures/xml/bcrypt_hasher.xml | 19 + .../Fixtures/xml/container1.xml | 14 +- .../Fixtures/xml/legacy_encoders.xml | 83 ++++ .../Fixtures/xml/migrating_encoder.xml | 2 +- .../Fixtures/xml/migrating_hasher.xml | 21 + .../Fixtures/xml/sodium_encoder.xml | 2 +- .../Fixtures/xml/sodium_hasher.xml | 19 + .../Fixtures/yml/argon2i_encoder.yml | 2 +- .../Fixtures/yml/argon2i_hasher.yml | 9 + .../Fixtures/yml/bcrypt_encoder.yml | 2 +- .../Fixtures/yml/bcrypt_hasher.yml | 8 + .../Fixtures/yml/container1.yml | 4 +- .../Fixtures/yml/legacy_encoders.yml | 87 +++++ .../Fixtures/yml/migrating_encoder.yml | 2 +- .../Fixtures/yml/migrating_hasher.yml | 10 + .../Fixtures/yml/sodium_encoder.yml | 2 +- .../Fixtures/yml/sodium_hasher.yml | 9 + .../UserPasswordEncoderCommandTest.php | 3 +- .../app/AbstractTokenCompareRoles/config.yml | 2 +- .../Functional/app/Authenticator/security.yml | 2 +- .../Functional/app/ClearRememberMe/config.yml | 2 +- .../app/CsrfFormLogin/base_config.yml | 2 +- .../app/FirewallEntryPoint/config.yml | 2 +- .../Tests/Functional/app/Guarded/config.yml | 2 +- .../Tests/Functional/app/JsonLogin/config.yml | 2 +- .../app/JsonLogin/custom_handlers.yml | 2 +- .../Functional/app/Logout/config_access.yml | 2 +- .../app/Logout/config_cookie_clearing.yml | 2 +- .../config.yml | 2 +- .../app/RememberMeLogout/config.yml | 2 +- .../app/StandardFormLogin/config.yml | 2 +- .../invalid_ip_access_control.yml | 2 +- .../localized_form_failure_handler.yml | 2 +- .../StandardFormLogin/localized_routes.yml | 2 +- .../Bundle/SecurityBundle/composer.json | 5 +- .../Component/PasswordHasher/.gitattributes | 4 + .../Component/PasswordHasher/.gitignore | 3 + .../Component/PasswordHasher/CHANGELOG.md | 4 + .../Command/UserPasswordHashCommand.php | 213 +++++++++++ .../Exception/ExceptionInterface.php | 21 + .../Exception/InvalidPasswordException.php | 23 ++ .../Exception/LogicException.php | 19 + .../Hasher/CheckPasswordLengthTrait.php | 25 ++ .../Hasher/MessageDigestPasswordHasher.php | 98 +++++ .../Hasher/MigratingPasswordHasher.php | 65 ++++ .../Hasher/NativePasswordHasher.php | 112 ++++++ .../Hasher/PasswordHasherAwareInterface.php | 26 ++ .../Hasher/PasswordHasherFactory.php | 216 +++++++++++ .../Hasher/PasswordHasherFactoryInterface.php | 33 ++ .../Hasher/Pbkdf2PasswordHasher.php | 90 +++++ .../Hasher/PlaintextPasswordHasher.php | 82 ++++ .../Hasher/SodiumPasswordHasher.php | 110 ++++++ .../Hasher/UserPasswordHasher.php | 64 ++++ .../Hasher/UserPasswordHasherInterface.php | 37 ++ src/Symfony/Component/PasswordHasher/LICENSE | 19 + .../LegacyPasswordHasherInterface.php | 38 ++ .../PasswordHasherInterface.php | 43 +++ .../Component/PasswordHasher/README.md | 40 ++ .../Command/UserPasswordHashCommandTest.php | 362 ++++++++++++++++++ .../MessageDigestPasswordHasherTest.php | 60 +++ .../Hasher/MigratingPasswordHasherTest.php | 68 ++++ .../Tests/Hasher/NativePasswordHasherTest.php | 105 +++++ .../Hasher/PasswordHasherFactoryTest.php | 216 +++++++++++ .../Tests/Hasher/Pbkdf2PasswordHasherTest.php | 60 +++ .../Hasher/PlaintextPasswordHasherTest.php | 56 +++ .../Tests/Hasher/SodiumPasswordHasherTest.php | 85 ++++ .../Tests/Hasher/UserPasswordHasherTest.php | 95 +++++ .../Component/PasswordHasher/composer.json | 33 ++ .../Component/PasswordHasher/phpunit.xml.dist | 31 ++ src/Symfony/Component/Security/CHANGELOG.md | 1 + .../AuthenticationProviderManager.php | 4 + .../Provider/DaoAuthenticationProvider.php | 37 +- .../Core/Encoder/BasePasswordEncoder.php | 6 + .../Core/Encoder/EncoderAwareInterface.php | 4 + .../Security/Core/Encoder/EncoderFactory.php | 8 +- .../Core/Encoder/EncoderFactoryInterface.php | 5 + .../Core/Encoder/LegacyEncoderTrait.php | 56 +++ .../Encoder/MessageDigestPasswordEncoder.php | 58 +-- .../Core/Encoder/MigratingPasswordEncoder.php | 9 +- .../Core/Encoder/NativePasswordEncoder.php | 98 +---- .../Core/Encoder/PasswordEncoderInterface.php | 5 + .../Core/Encoder/Pbkdf2PasswordEncoder.php | 55 +-- .../Core/Encoder/PlaintextPasswordEncoder.php | 40 +- .../Encoder/SelfSaltingEncoderInterface.php | 6 + .../Core/Encoder/SodiumPasswordEncoder.php | 94 +---- .../Core/Encoder/UserPasswordEncoder.php | 5 + .../Encoder/UserPasswordEncoderInterface.php | 5 + .../DaoAuthenticationProviderTest.php | 95 +++-- .../Core/Tests/Encoder/EncoderFactoryTest.php | 28 ++ .../MessageDigestPasswordEncoderTest.php | 3 + .../Encoder/MigratingPasswordEncoderTest.php | 3 + .../Encoder/NativePasswordEncoderTest.php | 1 + .../Encoder/Pbkdf2PasswordEncoderTest.php | 3 + .../Encoder/PlaintextPasswordEncoderTest.php | 3 + .../Encoder/SodiumPasswordEncoderTest.php | 3 + .../Encoder/TestPasswordEncoderInterface.php | 3 + .../Tests/Encoder/UserPasswordEncoderTest.php | 3 + .../Core/User/PasswordUpgraderInterface.php | 4 +- .../Security/Core/User/UserInterface.php | 12 +- .../Constraints/UserPasswordValidator.php | 19 +- .../Component/Security/Core/composer.json | 3 +- .../Provider/GuardAuthenticationProvider.php | 22 +- .../CheckCredentialsListener.php | 27 +- .../PasswordMigratingListener.php | 22 +- .../HttpBasicAuthenticatorTest.php | 18 +- .../CheckCredentialsListenerTest.php | 41 +- .../PasswordMigratingListenerTest.php | 24 +- .../Component/Security/Http/composer.json | 2 +- 137 files changed, 4066 insertions(+), 492 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_hasher.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/bcrypt_hasher.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/legacy_encoders.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/migrating_hasher.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/sodium_hasher.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_hasher.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/bcrypt_hasher.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/legacy_encoders.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/migrating_hasher.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/sodium_hasher.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_hasher.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/bcrypt_hasher.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/legacy_encoders.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/migrating_hasher.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/sodium_hasher.yml create mode 100644 src/Symfony/Component/PasswordHasher/.gitattributes create mode 100644 src/Symfony/Component/PasswordHasher/.gitignore create mode 100644 src/Symfony/Component/PasswordHasher/CHANGELOG.md create mode 100644 src/Symfony/Component/PasswordHasher/Command/UserPasswordHashCommand.php create mode 100644 src/Symfony/Component/PasswordHasher/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/PasswordHasher/Exception/InvalidPasswordException.php create mode 100644 src/Symfony/Component/PasswordHasher/Exception/LogicException.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/CheckPasswordLengthTrait.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/MessageDigestPasswordHasher.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/MigratingPasswordHasher.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/NativePasswordHasher.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherAwareInterface.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactory.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/PasswordHasherFactoryInterface.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/Pbkdf2PasswordHasher.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/PlaintextPasswordHasher.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/SodiumPasswordHasher.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasher.php create mode 100644 src/Symfony/Component/PasswordHasher/Hasher/UserPasswordHasherInterface.php create mode 100644 src/Symfony/Component/PasswordHasher/LICENSE create mode 100644 src/Symfony/Component/PasswordHasher/LegacyPasswordHasherInterface.php create mode 100644 src/Symfony/Component/PasswordHasher/PasswordHasherInterface.php create mode 100644 src/Symfony/Component/PasswordHasher/README.md create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Command/UserPasswordHashCommandTest.php create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Hasher/MessageDigestPasswordHasherTest.php create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Hasher/MigratingPasswordHasherTest.php create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Hasher/NativePasswordHasherTest.php create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Hasher/PasswordHasherFactoryTest.php create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Hasher/Pbkdf2PasswordHasherTest.php create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Hasher/PlaintextPasswordHasherTest.php create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Hasher/SodiumPasswordHasherTest.php create mode 100644 src/Symfony/Component/PasswordHasher/Tests/Hasher/UserPasswordHasherTest.php create mode 100644 src/Symfony/Component/PasswordHasher/composer.json create mode 100644 src/Symfony/Component/PasswordHasher/phpunit.xml.dist create mode 100644 src/Symfony/Component/Security/Core/Encoder/LegacyEncoderTrait.php diff --git a/UPGRADE-5.3.md b/UPGRADE-5.3.md index 22fab6fc0f..d4b8bebe17 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 835222fdd3..a6ee83505c 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 c39d3cb115..41e1911b6e 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 c5256e2ba3..0f66c585ed 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 4e1ccb8d2b..d50c23959b 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 8352bc41a8..4b573a061d 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\ConsoleOutputInterface; 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 @@ use Symfony\Component\Security\Core\Encoder\SelfSaltingEncoderInterface; * @author Sarah Khalil * * @final + * + * @deprecated since Symfony 5.3, use {@link UserPasswordHashCommand} instead */ class UserPasswordEncoderCommand extends Command { @@ -107,6 +110,8 @@ EOF $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 3d3000d8cd..6befc9319b 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -65,6 +65,23 @@ class MainConfiguration implements ConfigurationInterface 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 @@ class MainConfiguration implements ConfigurationInterface ; $this->addEncodersSection($rootNode); + $this->addPasswordHashersSection($rootNode); $this->addProvidersSection($rootNode); $this->addFirewallsSection($rootNode, $this->factories); $this->addAccessControlSection($rootNode); @@ -401,6 +419,57 @@ class MainConfiguration implements ConfigurationInterface ; } + 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 f991514ab4..368f4acf9f 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\Loader\PhpFileLoader; 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 @@ class SecurityExtension extends Extension implements PrependExtensionInterface $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 @@ class SecurityExtension extends Extension implements PrependExtensionInterface $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 @@ class SecurityExtension extends Extension implements PrependExtensionInterface // 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'; @@ -718,15 +732,15 @@ class SecurityExtension extends Extension implements PrependExtensionInterface return [ 'class' => NativePasswordEncoder::class, 'arguments' => [ - $config['time_cost'], - (($config['memory_cost'] ?? 0) << 10) ?: null, - $config['cost'], - ] + (isset($config['native_algorithm']) ? [3 => $config['native_algorithm']] : []), + $config['time_cost'], + (($config['memory_cost'] ?? 0) << 10) ?: null, + $config['cost'], + ] + (isset($config['native_algorithm']) ? [3 => $config['native_algorithm']] : []), ]; } 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.'); } @@ -743,6 +757,117 @@ class SecurityExtension extends Extension implements PrependExtensionInterface 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, + $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' => SodiumPasswordHasher::class, + 'arguments' => [ + $config['time_cost'], + (($config['memory_cost'] ?? 0) << 10) ?: null, + ], + ]; + } + + // run-time configured hasher + return $config; + } + // Parses user providers and returns an array of their ids private function createUserProviders(array $config, ContainerBuilder $container): array { diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/console.php index 61bc1f553e..5bfe8a2c3a 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 @@ return static function (ContainerConfigurator $container) { 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 f8b79cb356..60677a94de 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/guard.php @@ -34,7 +34,7 @@ return static function (ContainerConfigurator $container) { 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 0000000000..50e1be8d98 --- /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 8ff0d5e46d..01e1e9eba0 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 ecc9fc14fc..e4d3c49f88 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\FirewallContext; 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 @@ return static function (ContainerConfigurator $container) { ->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 @@ return static function (ContainerConfigurator $container) { ->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 3d0c6ddcb4..1bd7723634 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 @@ return static function (ContainerConfigurator $container) { // 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 @@ return static function (ContainerConfigurator $container) { ->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 7683ea2484..aa6a522de1 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 @@ return static function (ContainerConfigurator $container) { 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 072e33aca6..7068821286 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\Config\Definition\Exception\InvalidConfigurationException; 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 @@ abstract class CompleteConfigurationTest extends TestCase ], $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 @@ abstract class CompleteConfigurationTest extends TestCase ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testEncodersWithLibsodium() { if (!SodiumPasswordEncoder::isSupported()) { @@ -385,6 +395,9 @@ abstract class CompleteConfigurationTest extends TestCase ]], $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 @@ abstract class CompleteConfigurationTest extends TestCase ]], $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 @@ abstract class CompleteConfigurationTest extends TestCase ]], $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + /** + * @group legacy + */ public function testEncodersWithBCrypt() { $container = $this->getContainer('bcrypt_encoder'); @@ -548,6 +567,279 @@ abstract class CompleteConfigurationTest extends TestCase ]], $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 @@ abstract class CompleteConfigurationTest extends TestCase $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 ddac043692..ba1e1328b0 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 0000000000..341f772e87 --- /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 d4511aeb55..0a0a69b6de 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 0000000000..a416b3440d --- /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 3c9e6104ee..f551131f00 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 @@ $container->loadFromExtension('security', [ '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 0000000000..3c9e6104ee --- /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 c7ad9f02ab..04a800a218 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 0000000000..342ea64805 --- /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 ec0851bdfa..3239ed0274 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 0000000000..3ec569ae9a --- /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 a4346f824e..d18ecd939c 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 0000000000..3dc2c685be --- /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 d81f3aa73a..2ac6f38dd4 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 0000000000..d4c5d3ded1 --- /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 84d68cc4fd..097a726db5 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 0000000000..84d68cc4fd --- /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 db0ca61b60..a4bd11688e 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 0000000000..a4a9d2010d --- /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 09e6cacef3..80ccadf451 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 0000000000..fd5cacef7b --- /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 cadf8eb1e9..f4571e678d 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 0000000000..1079d6e5f8 --- /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 3f1a526215..a5bd7d9b3b 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 0000000000..8e8397486d --- /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 03b9aaf6ef..0ac2c94b06 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 0000000000..03b9aaf6ef --- /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 9eda61c188..87943cac12 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 0000000000..8657b1ee74 --- /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 2d70ef0d9b..70b4455ce2 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 0000000000..955a0b2a20 --- /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 5846f386b7..cab7634893 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php @@ -24,6 +24,7 @@ use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; * Tests UserPasswordEncoderCommand. * * @author Sarah Khalil + * @group legacy */ class UserPasswordEncoderCommandTest extends AbstractWebTestCase { @@ -40,7 +41,7 @@ class UserPasswordEncoderCommandTest extends AbstractWebTestCase ], ['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 5c86da6252..2fc91cbcbf 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 a364148198..cb14f50bc2 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 274ef33204..65419d2d46 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 6b82dea8de..201e0b8fd1 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 25ef98650e..bcb5d374b0 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 101d0c5b1b..75fa554462 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 055fcee19b..5bb3de09a9 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 c5076cce6f..a725338ece 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 f49d2f292b..433e059fe3 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 f62cc61655..a97b1a3a9a 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 9d92ac82c3..21933f99d7 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 7f334ffcae..298574d2b1 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 b35ad3f4c9..4f3affbf24 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 c9fe56e56c..8254631e51 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 ced854a681..1a6df70790 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 b07be914d4..5daa020a6a 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 3d9adf3939..f2a710e1ce 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 0000000000..84c7add058 --- /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 0000000000..c49a5d8df5 --- /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 0000000000..22693a3bf9 --- /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 0000000000..e4112c9c4e --- /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 0000000000..2d80d8a78f --- /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 0000000000..dea9109bae --- /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 0000000000..a0c425fa6f --- /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 0000000000..2dce065ff8 --- /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 0000000000..0dd18b276b --- /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 0000000000..f48373c6e9 --- /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 0000000000..f147868ad8 --- /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 0000000000..58046bc56c --- /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 0000000000..03bbe7350c --- /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 0000000000..943a4003da --- /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 0000000000..dd2e742db7 --- /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 0000000000..bafe6bce89 --- /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 0000000000..613cccd037 --- /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 0000000000..bb11680665 --- /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 0000000000..13a5b51a8a --- /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 0000000000..9ff2d0d630 --- /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 0000000000..3faf96d2f4 --- /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 0000000000..6b35757838 --- /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 0000000000..6a54ecb335 --- /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 0000000000..03cca7acd9 --- /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 0000000000..a6c66aee21 --- /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 0000000000..145a5cc34e --- /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 0000000000..90f48267ce --- /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 0000000000..6c5205e374 --- /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 0000000000..50f9c8d13e --- /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 0000000000..dc24db632a --- /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 0000000000..2da309ae92 --- /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 0000000000..899723f2e4 --- /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 0000000000..2ed22ee706 --- /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 0000000000..ee4c67f305 --- /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 43efcebec4..7e0b6b2a33 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 e91c5d8144..c4099603ef 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\AuthenticationFailureEvent; 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 @@ class AuthenticationProviderManager implements AuthenticationManagerInterface 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 c65a950552..26beb6b945 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\PasswordUpgraderInterface; 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 @@ use Symfony\Component\Security\Core\User\UserProviderInterface; */ 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 @@ class DaoAuthenticationProvider extends UserAuthenticationProvider 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())) { + 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 (!$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 e067a48a37..9c014d9ee3 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 546f4f7337..70231e2ce3 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 d07891bf77..e90498a3da 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 @@ class EncoderFactory implements EncoderFactoryInterface { $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 2b9834b6a0..65fd12d81e 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 0000000000..d1263213fe --- /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 d769f2f470..d4b1fb54b3 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 cd10b32bf7..be178731e1 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 @@ namespace Symfony\Component\Security\Core\Encoder; * 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 83b7f3f1e8..b3bd4b54a2 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 @@ use Symfony\Component\Security\Core\Exception\BadCredentialsException; * @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 9d8d48f8db..ba9216ebe5 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 ab5e1a5340..a50ad01ea1 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 @@ use Symfony\Component\Security\Core\Exception\BadCredentialsException; * @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 90e7e3d5be..65fc850279 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 @@ use Symfony\Component\Security\Core\Exception\BadCredentialsException; * 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 37855b60cf..6bb983dd14 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 53c6660014..480adb4a14 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 @@ use Symfony\Component\Security\Core\Exception\LogicException; * @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 aeb2995646..bfe31a4a0f 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 522ec0b023..858e836768 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 57ed2d0bf7..20e75b8076 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 PHPUnit\Framework\TestCase; 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 @@ class DaoAuthenticationProviderTest extends TestCase $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 @@ class DaoAuthenticationProviderTest extends TestCase $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 @@ class DaoAuthenticationProviderTest extends TestCase ->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 @@ class DaoAuthenticationProviderTest extends TestCase ->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 @@ class DaoAuthenticationProviderTest extends TestCase ->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 @@ class DaoAuthenticationProviderTest extends TestCase 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 @@ class DaoAuthenticationProviderTest extends TestCase 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 @@ class DaoAuthenticationProviderTest extends TestCase 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 @@ class DaoAuthenticationProviderTest extends TestCase 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 @@ class DaoAuthenticationProviderTest extends TestCase { $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 @@ class DaoAuthenticationProviderTest extends TestCase 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 @@ class DaoAuthenticationProviderTest extends TestCase $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 a699999139..7b79986b82 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\NativePasswordEncoder; 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 @@ class EncoderFactoryTest extends TestCase (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 @@ class EncAwareUser extends SomeUser implements EncoderAwareInterface 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 c2b514bb6b..a354b0dbf2 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 PHPUnit\Framework\TestCase; 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 efa360ecb2..fbaf89b0b1 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 PHPUnit\Framework\TestCase; 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 c67bf8668b..9d864dfce0 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 @@ use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; /** * @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 db274716bd..000e07d659 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 PHPUnit\Framework\TestCase; 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 fb5e674567..398044035e 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 PHPUnit\Framework\TestCase; 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 b4073a1cfb..4bae5f89f3 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 PHPUnit\Framework\TestCase; 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 13e2d0d3b3..3764038e9a 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 @@ namespace Symfony\Component\Security\Core\Tests\Encoder; 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 0d72919abc..6f52fbf1b2 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\Encoder\UserPasswordEncoder; 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 9c65298b07..ef62023d53 100644 --- a/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php +++ b/src/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.php @@ -17,11 +17,11 @@ namespace Symfony\Component\Security\Core\User; 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 239eb0ed00..c005e3ca9c 100644 --- a/src/Symfony/Component/Security/Core/User/UserInterface.php +++ b/src/Symfony/Component/Security/Core/User/UserInterface.php @@ -15,7 +15,7 @@ namespace Symfony\Component\Security\Core\User; * 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 @@ interface UserInterface /** * 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 24b032484f..0181ccbcbb 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 @@ namespace Symfony\Component\Security\Core\Validator\Constraints; 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 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException; 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 @@ class UserPasswordValidator extends ConstraintValidator 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 48a6a46ec4..424c077569 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 49244680ad..4d2ec10899 100644 --- a/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php +++ b/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php @@ -13,6 +13,7 @@ namespace Symfony\Component\Security\Guard\Provider; 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\AuthenticatorInterface; 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 @@ class GuardAuthenticationProvider implements AuthenticationProviderInterface 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 bd0d85fd7a..95f9c88542 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\CustomCre 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 @@ use Symfony\Component\Security\Http\Event\CheckPassportEvent; */ 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 @@ class CheckCredentialsListener implements EventSubscriberInterface 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 fb1f229d6f..a3755ca3ab 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 @@ use Symfony\Component\Security\Http\Event\LoginSuccessEvent; */ 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 @@ class PasswordMigratingListener implements EventSubscriberInterface } $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 @@ class PasswordMigratingListener implements EventSubscriberInterface } } - $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 79e914965a..27e20917d0 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 @@ namespace Symfony\Component\Security\Http\Tests\Authenticator; 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 e903dcd22c..315d7ccce4 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 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; 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\Passport; 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 @@ class CheckCredentialsListenerTest extends TestCase */ 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 @@ class CheckCredentialsListenerTest extends TestCase $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 @@ class CheckCredentialsListenerTest extends TestCase */ 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 @@ class CheckCredentialsListenerTest extends TestCase 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 @@ class CheckCredentialsListenerTest extends TestCase 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 @@ class CheckCredentialsListenerTest extends TestCase 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 @@ class CheckCredentialsListenerTest extends TestCase 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 39a62e0f69..0e32433c5b 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 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; 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\SelfValidatingPasspor 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 @@ class PasswordMigratingListenerTest extends TestCase */ 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 @@ class PasswordMigratingListenerTest extends TestCase $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 @@ class PasswordMigratingListenerTest extends TestCase $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 f4d31854a8..c2297fc053 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",