Skip to content

Commit c417f15

Browse files
committed
[Form] Add hash_mapping option to PasswordType
1 parent e230b7d commit c417f15

File tree

8 files changed

+390
-0
lines changed

8 files changed

+390
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Deprecate calling `Button/Form::setParent()`, `ButtonBuilder/FormConfigBuilder::setDataMapper()`, `TransformationFailedException::setInvalidMessage()` without arguments
1010
* Change the signature of `FormConfigBuilderInterface::setDataMapper()` to `setDataMapper(?DataMapperInterface)`
1111
* Change the signature of `FormInterface::setParent()` to `setParent(?self)`
12+
* Add `PasswordHasherExtension` with support for `hash_property_path` option in `PasswordType`
1213

1314
6.1
1415
---
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Extension\PasswordHasher\EventListener;
13+
14+
use Symfony\Component\Form\Exception\InvalidConfigurationException;
15+
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
16+
use Symfony\Component\Form\FormEvent;
17+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
18+
use Symfony\Component\PropertyAccess\PropertyAccess;
19+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
20+
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
21+
22+
/**
23+
* @author Sébastien Alfaiate <[email protected]>
24+
*/
25+
class PasswordHasherListener
26+
{
27+
private array $passwords = [];
28+
29+
public function __construct(
30+
private UserPasswordHasherInterface $passwordHasher,
31+
private ?PropertyAccessorInterface $propertyAccessor = null,
32+
) {
33+
$this->propertyAccessor ??= PropertyAccess::createPropertyAccessor();
34+
}
35+
36+
public function registerPassword(FormEvent $event)
37+
{
38+
$form = $event->getForm();
39+
$parentForm = $form->getParent();
40+
$mapped = $form->getConfig()->getMapped();
41+
42+
if ($parentForm && $parentForm->getConfig()->getType()->getInnerType() instanceof RepeatedType) {
43+
$mapped = $parentForm->getConfig()->getMapped();
44+
$parentForm = $parentForm->getParent();
45+
}
46+
47+
if ($mapped) {
48+
throw new InvalidConfigurationException('The "hash_property_path" option cannot be used on mapped field.');
49+
}
50+
51+
if (!($user = $parentForm?->getData()) || !$user instanceof PasswordAuthenticatedUserInterface) {
52+
throw new InvalidConfigurationException(sprintf('The "hash_property_path" option only supports "%s" objects, "%s" given.', PasswordAuthenticatedUserInterface::class, get_debug_type($user)));
53+
}
54+
55+
$this->passwords[] = [
56+
'user' => $user,
57+
'property_path' => $form->getConfig()->getOption('hash_property_path'),
58+
'password' => $event->getData(),
59+
];
60+
}
61+
62+
public function hashPasswords(FormEvent $event)
63+
{
64+
$form = $event->getForm();
65+
66+
if (!$form->isRoot()) {
67+
return;
68+
}
69+
70+
if ($form->isValid()) {
71+
foreach ($this->passwords as $password) {
72+
$this->propertyAccessor->setValue(
73+
$password['user'],
74+
$password['property_path'],
75+
$this->passwordHasher->hashPassword($password['user'], $password['password'])
76+
);
77+
}
78+
}
79+
80+
$this->passwords = [];
81+
}
82+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Extension\PasswordHasher;
13+
14+
use Symfony\Component\Form\AbstractExtension;
15+
use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener;
16+
17+
/**
18+
* Integrates the PasswordHasher component with the Form library.
19+
*
20+
* @author Sébastien Alfaiate <[email protected]>
21+
*/
22+
class PasswordHasherExtension extends AbstractExtension
23+
{
24+
public function __construct(
25+
private PasswordHasherListener $passwordHasherListener,
26+
) {
27+
}
28+
29+
protected function loadTypeExtensions(): array
30+
{
31+
return [
32+
new Type\FormTypePasswordHasherExtension($this->passwordHasherListener),
33+
new Type\PasswordTypePasswordHasherExtension($this->passwordHasherListener),
34+
];
35+
}
36+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Extension\PasswordHasher\Type;
13+
14+
use Symfony\Component\Form\AbstractTypeExtension;
15+
use Symfony\Component\Form\Extension\Core\Type\FormType;
16+
use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener;
17+
use Symfony\Component\Form\FormBuilderInterface;
18+
use Symfony\Component\Form\FormEvents;
19+
20+
/**
21+
* @author Sébastien Alfaiate <[email protected]>
22+
*/
23+
class FormTypePasswordHasherExtension extends AbstractTypeExtension
24+
{
25+
public function __construct(
26+
private PasswordHasherListener $passwordHasherListener,
27+
) {
28+
}
29+
30+
public function buildForm(FormBuilderInterface $builder, array $options)
31+
{
32+
$builder->addEventListener(FormEvents::POST_SUBMIT, [$this->passwordHasherListener, 'hashPasswords']);
33+
}
34+
35+
public static function getExtendedTypes(): iterable
36+
{
37+
return [FormType::class];
38+
}
39+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Extension\PasswordHasher\Type;
13+
14+
use Symfony\Component\Form\AbstractTypeExtension;
15+
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
16+
use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener;
17+
use Symfony\Component\Form\FormBuilderInterface;
18+
use Symfony\Component\Form\FormEvents;
19+
use Symfony\Component\OptionsResolver\OptionsResolver;
20+
use Symfony\Component\PropertyAccess\PropertyPath;
21+
22+
/**
23+
* @author Sébastien Alfaiate <[email protected]>
24+
*/
25+
class PasswordTypePasswordHasherExtension extends AbstractTypeExtension
26+
{
27+
public function __construct(
28+
private PasswordHasherListener $passwordHasherListener,
29+
) {
30+
}
31+
32+
public function buildForm(FormBuilderInterface $builder, array $options)
33+
{
34+
if ($options['hash_property_path']) {
35+
$builder->addEventListener(FormEvents::POST_SUBMIT, [$this->passwordHasherListener, 'registerPassword']);
36+
}
37+
}
38+
39+
public function configureOptions(OptionsResolver $resolver)
40+
{
41+
$resolver->setDefaults([
42+
'hash_property_path' => null,
43+
]);
44+
45+
$resolver->setAllowedTypes('hash_property_path', ['null', 'string', PropertyPath::class]);
46+
47+
$resolver->setInfo('hash_property_path', 'A valid PropertyAccess syntax where the hashed password will be set.');
48+
}
49+
50+
public static function getExtendedTypes(): iterable
51+
{
52+
return [PasswordType::class];
53+
}
54+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Tests\Extension\PasswordHasher\Type;
13+
14+
use PHPUnit\Framework\MockObject\MockObject;
15+
use Symfony\Component\Form\Exception\InvalidConfigurationException;
16+
use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener;
17+
use Symfony\Component\Form\Extension\PasswordHasher\PasswordHasherExtension;
18+
use Symfony\Component\Form\Test\TypeTestCase;
19+
use Symfony\Component\Form\Tests\Fixtures\User;
20+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
21+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
22+
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
23+
24+
class PasswordTypePasswordHasherExtensionTest extends TypeTestCase
25+
{
26+
/**
27+
* @var MockObject&UserPasswordHasherInterface
28+
*/
29+
protected $passwordHasher;
30+
31+
protected function setUp(): void
32+
{
33+
if (!interface_exists(PasswordAuthenticatedUserInterface::class)) {
34+
$this->markTestSkipped('PasswordAuthenticatedUserInterface not available.');
35+
}
36+
37+
$this->passwordHasher = $this->createMock(UserPasswordHasher::class);
38+
39+
parent::setUp();
40+
}
41+
42+
protected function getExtensions()
43+
{
44+
return array_merge(parent::getExtensions(), [
45+
new PasswordHasherExtension(new PasswordHasherListener($this->passwordHasher)),
46+
]);
47+
}
48+
49+
public function testPasswordHashSuccess()
50+
{
51+
$user = new User();
52+
53+
$plainPassword = 'PlainPassword';
54+
$hashedPassword = 'HashedPassword';
55+
56+
$this->passwordHasher
57+
->expects($this->once())
58+
->method('hashPassword')
59+
->with($user, $plainPassword)
60+
->willReturn($hashedPassword)
61+
;
62+
63+
$this->assertNull($user->getPassword());
64+
65+
$form = $this->factory
66+
->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', $user)
67+
->add('plainPassword', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', [
68+
'hash_property_path' => 'password',
69+
'mapped' => false,
70+
])
71+
->getForm()
72+
;
73+
74+
$form->submit(['plainPassword' => $plainPassword]);
75+
76+
$this->assertTrue($form->isValid());
77+
$this->assertSame($user->getPassword(), $hashedPassword);
78+
}
79+
80+
public function testPasswordHashOnInvalidForm()
81+
{
82+
$user = new User();
83+
84+
$this->passwordHasher
85+
->expects($this->never())
86+
->method('hashPassword')
87+
;
88+
89+
$this->assertNull($user->getPassword());
90+
91+
$form = $this->factory
92+
->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', $user)
93+
->add('plainPassword', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', [
94+
'hash_property_path' => 'password',
95+
'mapped' => false,
96+
])
97+
->add('integer', 'Symfony\Component\Form\Extension\Core\Type\IntegerType', [
98+
'mapped' => false,
99+
])
100+
->getForm()
101+
;
102+
103+
$form->submit([
104+
'plainPassword' => 'PlainPassword',
105+
'integer' => 'text',
106+
]);
107+
108+
$this->assertFalse($form->isValid());
109+
$this->assertNull($user->getPassword());
110+
}
111+
112+
public function testPasswordHashOnInvalidData()
113+
{
114+
$this->expectException(InvalidConfigurationException::class);
115+
$this->expectExceptionMessage('The "hash_property_path" option only supports "Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface" objects, "array" given.');
116+
117+
$form = $this->factory
118+
->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', [])
119+
->add('plainPassword', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', [
120+
'hash_property_path' => 'password',
121+
'mapped' => false,
122+
])
123+
->getForm()
124+
;
125+
126+
$form->submit(['plainPassword' => 'PlainPassword']);
127+
}
128+
129+
public function testPasswordHashOnMappedFieldForbidden()
130+
{
131+
$this->expectException(InvalidConfigurationException::class);
132+
$this->expectExceptionMessage('The "hash_property_path" option cannot be used on mapped field.');
133+
134+
$form = $this->factory
135+
->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', new User())
136+
->add('password', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', [
137+
'hash_property_path' => 'password',
138+
'mapped' => true,
139+
])
140+
->getForm()
141+
;
142+
143+
$form->submit(['password' => 'PlainPassword']);
144+
}
145+
}

0 commit comments

Comments
 (0)