diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 7a9e329a6d814..cd514bb44367d 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -33,6 +33,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Form\Extension\PasswordHasher\PasswordHasherExtension; use Symfony\Component\HttpFoundation\ChainRequestMatcher; use Symfony\Component\HttpFoundation\RequestMatcher\AttributesRequestMatcher; use Symfony\Component\HttpFoundation\RequestMatcher\HostRequestMatcher; @@ -124,6 +125,12 @@ public function load(array $configs, ContainerBuilder $container) $container->removeDefinition('security.is_granted_attribute_expression_language'); } + if (!class_exists(PasswordHasherExtension::class)) { + $container->removeDefinition('form.listener.password_hasher'); + $container->removeDefinition('form.type_extension.form.password_hasher'); + $container->removeDefinition('form.type_extension.password.password_hasher'); + } + // set some global scalars $container->setParameter('security.access.denied_url', $config['access_denied_url']); $container->setParameter('security.authentication.manager.erase_credentials', $config['erase_credentials']); diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php index 50e1be8d981cd..2df037ad0d7ce 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/password_hasher.php @@ -11,6 +11,11 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener; +use Symfony\Component\Form\Extension\PasswordHasher\Type\FormTypePasswordHasherExtension; +use Symfony\Component\Form\Extension\PasswordHasher\Type\PasswordTypePasswordHasherExtension; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; @@ -26,5 +31,23 @@ ->args([service('security.password_hasher_factory')]) ->alias('security.password_hasher', 'security.user_password_hasher') ->alias(UserPasswordHasherInterface::class, 'security.password_hasher') + + ->set('form.listener.password_hasher', PasswordHasherListener::class) + ->args([ + service('security.password_hasher'), + service('property_accessor')->nullOnInvalid(), + ]) + + ->set('form.type_extension.form.password_hasher', FormTypePasswordHasherExtension::class) + ->args([ + service('form.listener.password_hasher'), + ]) + ->tag('form.type_extension', ['extended-type' => FormType::class]) + + ->set('form.type_extension.password.password_hasher', PasswordTypePasswordHasherExtension::class) + ->args([ + service('form.listener.password_hasher'), + ]) + ->tag('form.type_extension', ['extended-type' => PasswordType::class]) ; }; diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index 2475b005bed83..68622612e30b3 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Deprecate calling `Button/Form::setParent()`, `ButtonBuilder/FormConfigBuilder::setDataMapper()`, `TransformationFailedException::setInvalidMessage()` without arguments * Change the signature of `FormConfigBuilderInterface::setDataMapper()` to `setDataMapper(?DataMapperInterface)` * Change the signature of `FormInterface::setParent()` to `setParent(?self)` + * Add `PasswordHasherExtension` with support for `hash_property_path` option in `PasswordType` 6.1 --- diff --git a/src/Symfony/Component/Form/Extension/PasswordHasher/EventListener/PasswordHasherListener.php b/src/Symfony/Component/Form/Extension/PasswordHasher/EventListener/PasswordHasherListener.php new file mode 100644 index 0000000000000..c9cf0d4a34417 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/PasswordHasher/EventListener/PasswordHasherListener.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\Form\Extension\PasswordHasher\EventListener; + +use Symfony\Component\Form\Exception\InvalidConfigurationException; +use Symfony\Component\Form\Extension\Core\Type\RepeatedType; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; + +/** + * @author Sébastien Alfaiate + */ +class PasswordHasherListener +{ + private array $passwords = []; + + public function __construct( + private UserPasswordHasherInterface $passwordHasher, + private ?PropertyAccessorInterface $propertyAccessor = null, + ) { + $this->propertyAccessor ??= PropertyAccess::createPropertyAccessor(); + } + + public function registerPassword(FormEvent $event) + { + $form = $event->getForm(); + $parentForm = $form->getParent(); + $mapped = $form->getConfig()->getMapped(); + + if ($parentForm && $parentForm->getConfig()->getType()->getInnerType() instanceof RepeatedType) { + $mapped = $parentForm->getConfig()->getMapped(); + $parentForm = $parentForm->getParent(); + } + + if ($mapped) { + throw new InvalidConfigurationException('The "hash_property_path" option cannot be used on mapped field.'); + } + + if (!($user = $parentForm?->getData()) || !$user instanceof PasswordAuthenticatedUserInterface) { + throw new InvalidConfigurationException(sprintf('The "hash_property_path" option only supports "%s" objects, "%s" given.', PasswordAuthenticatedUserInterface::class, get_debug_type($user))); + } + + $this->passwords[] = [ + 'user' => $user, + 'property_path' => $form->getConfig()->getOption('hash_property_path'), + 'password' => $event->getData(), + ]; + } + + public function hashPasswords(FormEvent $event) + { + $form = $event->getForm(); + + if (!$form->isRoot()) { + return; + } + + if ($form->isValid()) { + foreach ($this->passwords as $password) { + $this->propertyAccessor->setValue( + $password['user'], + $password['property_path'], + $this->passwordHasher->hashPassword($password['user'], $password['password']) + ); + } + } + + $this->passwords = []; + } +} diff --git a/src/Symfony/Component/Form/Extension/PasswordHasher/PasswordHasherExtension.php b/src/Symfony/Component/Form/Extension/PasswordHasher/PasswordHasherExtension.php new file mode 100644 index 0000000000000..b9675c21535e7 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/PasswordHasher/PasswordHasherExtension.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\PasswordHasher; + +use Symfony\Component\Form\AbstractExtension; +use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener; + +/** + * Integrates the PasswordHasher component with the Form library. + * + * @author Sébastien Alfaiate + */ +class PasswordHasherExtension extends AbstractExtension +{ + public function __construct( + private PasswordHasherListener $passwordHasherListener, + ) { + } + + protected function loadTypeExtensions(): array + { + return [ + new Type\FormTypePasswordHasherExtension($this->passwordHasherListener), + new Type\PasswordTypePasswordHasherExtension($this->passwordHasherListener), + ]; + } +} diff --git a/src/Symfony/Component/Form/Extension/PasswordHasher/Type/FormTypePasswordHasherExtension.php b/src/Symfony/Component/Form/Extension/PasswordHasher/Type/FormTypePasswordHasherExtension.php new file mode 100644 index 0000000000000..7294ee5f022d9 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/PasswordHasher/Type/FormTypePasswordHasherExtension.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\PasswordHasher\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvents; + +/** + * @author Sébastien Alfaiate + */ +class FormTypePasswordHasherExtension extends AbstractTypeExtension +{ + public function __construct( + private PasswordHasherListener $passwordHasherListener, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addEventListener(FormEvents::POST_SUBMIT, [$this->passwordHasherListener, 'hashPasswords']); + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/src/Symfony/Component/Form/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtension.php b/src/Symfony/Component/Form/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtension.php new file mode 100644 index 0000000000000..e75a6e9ff2579 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtension.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\PasswordHasher\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\PropertyAccess\PropertyPath; + +/** + * @author Sébastien Alfaiate + */ +class PasswordTypePasswordHasherExtension extends AbstractTypeExtension +{ + public function __construct( + private PasswordHasherListener $passwordHasherListener, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + if ($options['hash_property_path']) { + $builder->addEventListener(FormEvents::POST_SUBMIT, [$this->passwordHasherListener, 'registerPassword']); + } + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'hash_property_path' => null, + ]); + + $resolver->setAllowedTypes('hash_property_path', ['null', 'string', PropertyPath::class]); + + $resolver->setInfo('hash_property_path', 'A valid PropertyAccess syntax where the hashed password will be set.'); + } + + public static function getExtendedTypes(): iterable + { + return [PasswordType::class]; + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtensionTest.php b/src/Symfony/Component/Form/Tests/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtensionTest.php new file mode 100644 index 0000000000000..75b2f992573db --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtensionTest.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\PasswordHasher\Type; + +use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Component\Form\Exception\InvalidConfigurationException; +use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener; +use Symfony\Component\Form\Extension\PasswordHasher\PasswordHasherExtension; +use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\Form\Tests\Fixtures\User; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; + +class PasswordTypePasswordHasherExtensionTest extends TypeTestCase +{ + /** + * @var MockObject&UserPasswordHasherInterface + */ + protected $passwordHasher; + + protected function setUp(): void + { + if (!interface_exists(PasswordAuthenticatedUserInterface::class)) { + $this->markTestSkipped('PasswordAuthenticatedUserInterface not available.'); + } + + $this->passwordHasher = $this->createMock(UserPasswordHasher::class); + + parent::setUp(); + } + + protected function getExtensions() + { + return array_merge(parent::getExtensions(), [ + new PasswordHasherExtension(new PasswordHasherListener($this->passwordHasher)), + ]); + } + + public function testPasswordHashSuccess() + { + $user = new User(); + + $plainPassword = 'PlainPassword'; + $hashedPassword = 'HashedPassword'; + + $this->passwordHasher + ->expects($this->once()) + ->method('hashPassword') + ->with($user, $plainPassword) + ->willReturn($hashedPassword) + ; + + $this->assertNull($user->getPassword()); + + $form = $this->factory + ->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', $user) + ->add('plainPassword', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', [ + 'hash_property_path' => 'password', + 'mapped' => false, + ]) + ->getForm() + ; + + $form->submit(['plainPassword' => $plainPassword]); + + $this->assertTrue($form->isValid()); + $this->assertSame($user->getPassword(), $hashedPassword); + } + + public function testPasswordHashOnInvalidForm() + { + $user = new User(); + + $this->passwordHasher + ->expects($this->never()) + ->method('hashPassword') + ; + + $this->assertNull($user->getPassword()); + + $form = $this->factory + ->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', $user) + ->add('plainPassword', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', [ + 'hash_property_path' => 'password', + 'mapped' => false, + ]) + ->add('integer', 'Symfony\Component\Form\Extension\Core\Type\IntegerType', [ + 'mapped' => false, + ]) + ->getForm() + ; + + $form->submit([ + 'plainPassword' => 'PlainPassword', + 'integer' => 'text', + ]); + + $this->assertFalse($form->isValid()); + $this->assertNull($user->getPassword()); + } + + public function testPasswordHashOnInvalidData() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The "hash_property_path" option only supports "Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface" objects, "array" given.'); + + $form = $this->factory + ->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', []) + ->add('plainPassword', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', [ + 'hash_property_path' => 'password', + 'mapped' => false, + ]) + ->getForm() + ; + + $form->submit(['plainPassword' => 'PlainPassword']); + } + + public function testPasswordHashOnMappedFieldForbidden() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The "hash_property_path" option cannot be used on mapped field.'); + + $form = $this->factory + ->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', new User()) + ->add('password', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', [ + 'hash_property_path' => 'password', + 'mapped' => true, + ]) + ->getForm() + ; + + $form->submit(['password' => 'PlainPassword']); + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/User.php b/src/Symfony/Component/Form/Tests/Fixtures/User.php new file mode 100644 index 0000000000000..486311ee6c2e8 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/User.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Fixtures; + +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; + +class User implements PasswordAuthenticatedUserInterface +{ + private $password; + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } +} diff --git a/src/Symfony/Component/Form/composer.json b/src/Symfony/Component/Form/composer.json index 05193a2f0c2e8..60f07fddedabf 100644 --- a/src/Symfony/Component/Form/composer.json +++ b/src/Symfony/Component/Form/composer.json @@ -37,6 +37,7 @@ "symfony/http-foundation": "^5.4|^6.0", "symfony/http-kernel": "^5.4|^6.0", "symfony/intl": "^5.4|^6.0", + "symfony/security-core": "^6.2", "symfony/security-csrf": "^5.4|^6.0", "symfony/translation": "^5.4|^6.0", "symfony/var-dumper": "^5.4|^6.0", @@ -56,6 +57,7 @@ }, "suggest": { "symfony/validator": "For form validation.", + "symfony/security-core": "For hashing users passwords.", "symfony/security-csrf": "For protecting forms against CSRF attacks.", "symfony/twig-bridge": "For templating with Twig." },