Skip to content

Commit 6f07272

Browse files
Merge pull request #577 from components-web-app/fix/570-password-validation
Validate a password set through the API before hashing it; a password change no longer verifies the email (#570)
2 parents 3a21ea6 + 3396609 commit 6f07272

12 files changed

Lines changed: 319 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio
99

1010
### Fixed
1111
- A user referenced by IRI from another resource's write is no longer processed as a new registration, which reset their `emailAddressVerified` ([#575](https://github.com/components-web-app/api-components-bundle/pull/575))
12+
- A `plainPassword` set through the API is validated (6 to 4096 characters, 422 on `plainPassword`) before it is hashed, and setting a password no longer marks the email address verified; only a password reset does ([#577](https://github.com/components-web-app/api-components-bundle/pull/577))
1213

1314
## [2.0.0-alpha.39](https://github.com/components-web-app/api-components-bundle/compare/2.0.0-alpha.38...2.0.0-alpha.39) - 2026-10-30
1415

‎CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ Resources are fetched and cached **individually**. Never embed related data outs
296296

297297
- **A `PUT` on a user merges, never replaces** (#426). API Platform's `standard_put` defaults to `true` on 4.x and 5.x: the body is denormalized into a new object and every property is copied onto the stored one, including those nobody can write (password hash, tokens), so they took the constructor's defaults. `UserResourceMetadataCollectionFactory` sets `standard_put: false` on every `Put` of an `AbstractUser`, over any application default. `User\UserPutTest` pins it.
298298
- **`UserNormalizer` handles only a user payload (an array)** (#572). A user referenced by IRI from another resource's write reaches the serializer as a string; running `processChanges()` on it treated the stored user as a new registration and reset `emailAddressVerified`. `User\UserReferenceTest` pins it.
299+
- **A plain password is hashed only after validation, and never verifies the email address** (#570). `UserDataProcessor::processChanges()` does not hash: the API hashes in `UserPasswordStateProcessor` (on `state_processor.locator`, before the persist) and the form listener after `processChanges()`, so the `plainPassword` `Length` (in `Default` as well as `User:password:create`) sees the value on API writes; hashed during denormalization, it was already erased. Only a password reset, which proves the inbox, sets `emailAddressVerified`. `User\UserPasswordTest` pins it.
299300
- **User email links** (#316): `RefererUrlResolver` uses `Origin`, else `Referer`, reduced to `scheme://host[:port]`, only if it matches `user.email_links.allowed_origins` (anchored by the bundle as `{\A(?:p)\z}i`), else `default_origin`, else the link is refused. The request host is not implicitly trusted. Request input may only be a `RelativeUrlPath`. **Never build an outbound link from a request header without an allow-list.**
300301
- **A refused email link depends on which kind of email it is** (#326). Where the email is the job (password reset, both resends), the request is a 400 that changes nothing: build and send the email before flushing the token it carries. After a completed write (every email from `UserWriteNotifier::notify()`), the write keeps its status and `UserMailer::afterWrite()` logs the refusal at error; the verify and change-email emails serve both kinds, so the notifier must call their `…AfterWrite` variants. Only `UnparseableRequestHeaderException` is caught.
301302
- **A throttled email request is a 429 with `Retry-After`** (#331), never a 200 that sends nothing. `UserDataProcessor` throws `RequestLimitReachedException` carrying the seconds remaining; the action returns it with nothing sent or changed. Each flow has its own `repeat_ttl_seconds`: `password_reset` (86400), `new_email_confirmation` (300) and `email_verification` (300). Never share one throttle between flows.
@@ -468,7 +469,7 @@ The bundle does not set `use_symfony_listeners`. API Platform's default (`false`
468469
| `api_platform.state_provider.read`, negative priority, so it wraps the `security` access checkers (0, 10) | `POST_READ`, `PRE_DESERIALIZE` | deny access (-20), publishable merge (-30), component usage (-40), upload and download (-50), resource version (-60) |
469470
| `api_platform.state_provider.deserialize` | `POST_DESERIALIZE` | publication date guard (-10) |
470471
| `api_platform.state_provider.validate` | `POST_VALIDATE` | route generation (-10) |
471-
| `api_platform.state_processor.locator`, the persist/remove call | `PRE_WRITE` before the inner call, `POST_WRITE` after it | version conflict (5), publishable (10), user emails (15), uploadable (20), delete cascade (30), position removal (40), route redirects (50), position placement (60) |
472+
| `api_platform.state_processor.locator`, the persist/remove call | `PRE_WRITE` before the inner call, `POST_WRITE` after it | version conflict (5), publishable (10), user emails (15), user password hash (16), uploadable (20), delete cascade (30), position removal (40), route redirects (50), position placement (60) |
472473
| `api_platform.state_processor.serialize` | `PRE_SERIALIZE` | form (10), collection (20) |
473474

474475
- **Never decorate `state_processor.write` or `state_*.main`.** In controller mode, `write`'s inner call is serialize and respond, so its "after" runs after serialization. The `main` services do not exist in listener mode.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
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 Silverback\ApiComponentsBundle\DataProcessor\StateProcessor;
13+
14+
use ApiPlatform\Metadata\HttpOperation;
15+
use ApiPlatform\Metadata\Operation;
16+
use ApiPlatform\State\ProcessorInterface;
17+
use Silverback\ApiComponentsBundle\Entity\User\AbstractUser;
18+
use Silverback\ApiComponentsBundle\Helper\User\UserDataProcessor;
19+
20+
/**
21+
* @implements ProcessorInterface<mixed, mixed>
22+
*/
23+
final readonly class UserPasswordStateProcessor implements ProcessorInterface
24+
{
25+
/**
26+
* @param ProcessorInterface<mixed, mixed> $decorated
27+
*/
28+
public function __construct(
29+
private ProcessorInterface $decorated,
30+
private UserDataProcessor $userDataProcessor,
31+
) {
32+
}
33+
34+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
35+
{
36+
if ($data instanceof AbstractUser && $operation instanceof HttpOperation) {
37+
$this->userDataProcessor->hashPassword($data);
38+
}
39+
40+
return $this->decorated->process($data, $operation, $uriVariables, $context);
41+
}
42+
}

‎src/Entity/User/AbstractUser.php‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ abstract class AbstractUser implements SymfonyUserInterface, PasswordAuthenticat
6767
protected string $password;
6868

6969
#[Assert\NotBlank(message: 'Please enter your desired password.', groups: ['User:password:create'])]
70-
#[Assert\Length(min: 6, max: 4096, minMessage: 'Your password must be more than 6 characters long.', maxMessage: 'Your password cannot be over 4096 characters', groups: ['User:password:create'])]
70+
#[Assert\Length(min: 6, max: 4096, minMessage: 'Your password must be more than 6 characters long.', maxMessage: 'Your password cannot be over 4096 characters', groups: ['Default', 'User:password:create'])]
7171
#[ApiProperty(readable: false)]
7272
#[Groups(['User:input'])]
7373
protected ?string $plainPassword = null;
@@ -225,7 +225,6 @@ public function setPlainPassword(?string $plainPassword): self
225225
{
226226
$this->plainPassword = $plainPassword;
227227
if ($plainPassword) {
228-
// Needs to update mapped field to trigger update event which will encode the plain password
229228
$this->passwordUpdatedAt = new \DateTime();
230229
}
231230

‎src/EventListener/Form/EntityPersistFormListener.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public function __invoke(FormSuccessEvent $event): void
102102
}
103103

104104
$this->userDataProcessor->processChanges($data, $oldUser);
105+
$this->userDataProcessor->hashPassword($data);
105106
$this->userWriteNotifier->notify($data, $oldUser);
106107
}
107108

‎src/Helper/User/UserDataProcessor.php‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,14 @@ public function passwordReset(string $username, string $token, string $newPasswo
6767
$user->setPlainPassword($newPassword);
6868
$user->setNewPasswordConfirmationToken(null);
6969
$user->setPasswordRequestedAt(null);
70+
$user->setEmailAddressVerified(true);
7071
$this->hashPassword($user);
7172

7273
return $user;
7374
}
7475

7576
public function processChanges(AbstractUser $user, ?AbstractUser $previousUser): void
7677
{
77-
$this->hashPassword($user);
7878
if (!$previousUser) {
7979
$user->setEmailAddressVerified($this->initialEmailVerifiedState);
8080
if (!$this->initialEmailVerifiedState && $this->verifyEmailOnRegister) {
@@ -151,14 +151,13 @@ private function setEmailAddressVerifyToken(AbstractUser $user): void
151151
$user->plainEmailAddressVerifyToken = $token;
152152
}
153153

154-
private function hashPassword(AbstractUser $entity): void
154+
public function hashPassword(AbstractUser $entity): void
155155
{
156156
if (!$entity->getPlainPassword()) {
157157
return;
158158
}
159159
$encoded = $this->passwordHasher->hashPassword($entity, $entity->getPlainPassword());
160160
$entity->setPassword($encoded);
161-
$entity->setEmailAddressVerified(true);
162161
$entity->eraseCredentials();
163162
}
164163
}

‎src/Resources/config/services.php‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\RouteRedirectStateProcessor;
6767
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UploadableWriteStateProcessor;
6868
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UserNotificationStateProcessor;
69+
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UserPasswordStateProcessor;
6970
use Silverback\ApiComponentsBundle\DataProvider\PageDataProvider;
7071
use Silverback\ApiComponentsBundle\DataProvider\StateProvider\ComponentGroupOrderStateProvider;
7172
use Silverback\ApiComponentsBundle\DataProvider\StateProvider\ComponentGroupStateProvider;
@@ -1915,6 +1916,18 @@
19151916
);
19161917
$services->alias(UserNotificationStateProcessor::class, 'silverback.api_components.api_platform.state_processor.user_notification');
19171918

1919+
$services
1920+
->set('silverback.api_components.api_platform.state_processor.user_password')
1921+
->class(UserPasswordStateProcessor::class)
1922+
->decorate('api_platform.state_processor.locator', null, 16)
1923+
->args(
1924+
[
1925+
new Reference('silverback.api_components.api_platform.state_processor.user_password.inner'),
1926+
new Reference(UserDataProcessor::class),
1927+
]
1928+
);
1929+
$services->alias(UserPasswordStateProcessor::class, 'silverback.api_components.api_platform.state_processor.user_password');
1930+
19181931
$services
19191932
->set('silverback.api_components.factory.user')
19201933
->class(UserFactory::class)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
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 Silverback\ApiComponentsBundle\Tests\DataProcessor\StateProcessor;
13+
14+
use ApiPlatform\Metadata\GraphQl\Mutation;
15+
use ApiPlatform\Metadata\Patch;
16+
use ApiPlatform\Metadata\Post;
17+
use ApiPlatform\State\ProcessorInterface;
18+
use PHPUnit\Framework\TestCase;
19+
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UserPasswordStateProcessor;
20+
use Silverback\ApiComponentsBundle\Helper\User\UserDataProcessor;
21+
use Silverback\ApiComponentsBundle\Repository\User\UserRepositoryInterface;
22+
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\User;
23+
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
24+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
25+
26+
final class UserPasswordStateProcessorTest extends TestCase
27+
{
28+
public function test_a_users_plain_password_is_hashed_before_the_write(): void
29+
{
30+
$user = $this->userWithNewPassword();
31+
$operation = new Patch();
32+
$inner = $this->createMock(ProcessorInterface::class);
33+
$inner->expects(self::once())->method('process')
34+
->with(self::callback(static fn (User $written): bool => 'hashed' === $written->getPassword() && null === $written->getPlainPassword()), $operation, ['id' => 1], ['previous_data' => null])
35+
->willReturn('written');
36+
37+
self::assertSame('written', $this->processor($inner)->process($user, $operation, ['id' => 1], ['previous_data' => null]));
38+
}
39+
40+
public function test_a_user_written_through_an_operation_that_is_not_http_is_not_hashed(): void
41+
{
42+
$user = $this->userWithNewPassword();
43+
44+
self::assertSame('written', $this->processor($this->inner())->process($user, new Mutation()));
45+
self::assertSame('old', $user->getPassword());
46+
self::assertSame('new password', $user->getPlainPassword());
47+
}
48+
49+
public function test_data_other_than_a_user_is_passed_to_the_write(): void
50+
{
51+
$data = new \stdClass();
52+
$inner = $this->createMock(ProcessorInterface::class);
53+
$inner->expects(self::once())->method('process')->with($data)->willReturn('written');
54+
55+
self::assertSame('written', $this->processor($inner)->process($data, new Post()));
56+
}
57+
58+
private function userWithNewPassword(): User
59+
{
60+
$user = new User('daniel', 'daniel@example.com');
61+
$user->setPassword('old')->setPlainPassword('new password');
62+
63+
return $user;
64+
}
65+
66+
private function processor(ProcessorInterface $inner): UserPasswordStateProcessor
67+
{
68+
$hasher = $this->createStub(UserPasswordHasherInterface::class);
69+
$hasher->method('hashPassword')->willReturn('hashed');
70+
71+
return new UserPasswordStateProcessor($inner, new UserDataProcessor($hasher, $this->createStub(UserRepositoryInterface::class), $this->createStub(PasswordHasherFactoryInterface::class), false, false, false));
72+
}
73+
74+
private function inner(): ProcessorInterface
75+
{
76+
$inner = $this->createStub(ProcessorInterface::class);
77+
$inner->method('process')->willReturn('written');
78+
79+
return $inner;
80+
}
81+
}

‎tests/DependencyInjection/StateDecoratorServicesTest.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\RouteRedirectStateProcessor;
2424
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UploadableWriteStateProcessor;
2525
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UserNotificationStateProcessor;
26+
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UserPasswordStateProcessor;
2627
use Silverback\ApiComponentsBundle\DataProvider\StateProvider\ComponentUsageStateProvider;
2728
use Silverback\ApiComponentsBundle\DataProvider\StateProvider\DenyAccessStateProvider;
2829
use Silverback\ApiComponentsBundle\DataProvider\StateProvider\DownloadStateProvider;
@@ -64,6 +65,12 @@ public static function decorators(): iterable
6465
'api_platform.state_processor.locator',
6566
15,
6667
];
68+
yield 'user password hashed before the persist, after validation, so the notification sees the new hash' => [
69+
'silverback.api_components.api_platform.state_processor.user_password',
70+
UserPasswordStateProcessor::class,
71+
'api_platform.state_processor.locator',
72+
16,
73+
];
6774
yield 'route generation after validation and its security check' => [
6875
'silverback.api_components.api_platform.state_provider.route_generate',
6976
RouteGenerateStateProvider::class,

‎tests/EventListener/Form/EntityPersistFormListenerTest.php‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Silverback\ApiComponentsBundle\Tests\EventListener\Form;
1313

14+
use Doctrine\ORM\EntityManagerInterface;
15+
use Doctrine\ORM\UnitOfWork;
1416
use Doctrine\Persistence\ManagerRegistry;
1517
use Doctrine\Persistence\ObjectManager;
1618
use PHPUnit\Framework\TestCase;
@@ -24,9 +26,12 @@
2426
use Silverback\ApiComponentsBundle\Helper\User\UserMailer;
2527
use Silverback\ApiComponentsBundle\Helper\User\UserWriteNotifier;
2628
use Silverback\ApiComponentsBundle\Model\Form\FormView;
29+
use Silverback\ApiComponentsBundle\Repository\User\UserRepositoryInterface;
2730
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\User;
2831
use Symfony\Component\Form\Extension\Core\Type\FormType;
2932
use Symfony\Component\Form\Forms;
33+
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
34+
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
3035
use Symfony\Component\Serializer\Encoder\JsonEncoder;
3136
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
3237
use Symfony\Component\Serializer\Serializer;
@@ -57,6 +62,25 @@ public function test_data_other_than_a_user_is_persisted_by_any_object_manager()
5762
self::assertSame($data, $event->result);
5863
}
5964

65+
public function test_a_users_new_password_is_hashed_before_the_user_is_persisted(): void
66+
{
67+
$user = new User('bob', 'bob@example.com');
68+
$user->setPassword('old')->setPlainPassword('new password');
69+
$unitOfWork = $this->createStub(UnitOfWork::class);
70+
$unitOfWork->method('getOriginalEntityData')->willReturn([]);
71+
$manager = $this->createMock(EntityManagerInterface::class);
72+
$manager->method('getUnitOfWork')->willReturn($unitOfWork);
73+
$manager->expects(self::once())->method('persist')->with(self::callback(static fn (User $persisted): bool => 'hashed' === $persisted->getPassword() && null === $persisted->getPlainPassword()));
74+
$manager->expects(self::once())->method('flush');
75+
$hasher = $this->createStub(UserPasswordHasherInterface::class);
76+
$hasher->method('hashPassword')->willReturn('hashed');
77+
$processor = new UserDataProcessor($hasher, $this->createStub(UserRepositoryInterface::class), $this->createStub(PasswordHasherFactoryInterface::class), true, false, false);
78+
79+
$this->createListener(User::class, $manager, $processor)($this->createEvent($user));
80+
81+
self::assertSame('hashed', $user->getPassword());
82+
}
83+
6084
public function test_the_normalizer_must_also_denormalize(): void
6185
{
6286
$listener = new class('form_type', User::class) extends EntityPersistFormListener {
@@ -73,7 +97,7 @@ public function test_the_normalizer_must_also_denormalize(): void
7397
);
7498
}
7599

76-
private function createListener(string $dataClass, ObjectManager $manager): EntityPersistFormListener
100+
private function createListener(string $dataClass, ObjectManager $manager, ?UserDataProcessor $userDataProcessor = null): EntityPersistFormListener
77101
{
78102
$registry = $this->createStub(ManagerRegistry::class);
79103
$registry->method('getManagerForClass')->willReturn($manager);
@@ -86,7 +110,7 @@ private function createListener(string $dataClass, ObjectManager $manager): Enti
86110
$this->createStub(TimestampedDataPersister::class),
87111
new UserWriteNotifier($this->createStub(UserMailer::class)),
88112
new Serializer([], [new JsonEncoder()]),
89-
$this->createStub(UserDataProcessor::class),
113+
$userDataProcessor ?? $this->createStub(UserDataProcessor::class),
90114
);
91115

92116
return $listener;

0 commit comments

Comments
 (0)