Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

State Processors: Hashing the User Password

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

When an API client creates a user, they send a password field, which gets set onto the plainPassword property. Now, we need to hash that password before the User is saved to the database. Like we showed when working with Foundry, hashing a password is simple: grab the UserPasswordHasherInterface service then call a method on it:

... lines 1 - 6
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
... lines 8 - 30
final class UserFactory extends ModelFactory
{
... lines 33 - 47
public function __construct(
private UserPasswordHasherInterface $passwordHasher
)
{
... line 52
}
... lines 54 - 81
protected function initialize(): self
{
return $this
->afterInstantiate(function(User $user): void {
$user->setPassword($this->passwordHasher->hashPassword(
$user,
$user->getPassword()
));
})
;
}
... lines 93 - 97
}

But to pull this off, we need a "hook" in API platform: we need some way to run code after our data is deserialized onto the User object, but before it's saved.

In our tutorial about API platform 2, we used a Doctrine listener for this, which would still work. Though, it does some negatives, like being super magical - it's hard to debug if it doesn't work - and you need to do some weird stuff to make sure it runs when editing a user's password.

Hello State Processors

Fortunately, In API platform 3, we have a shiny new tool that we can leverage. It's called a state processor. And actually, our User class is already using a state processor!

Find the API Platform 2 to 3 upgrade guide... and search for processor. Let's see... here we go. It has a section called providers and processors. We'll talk about providers later.

According to this, if you have an ApiResource class that is an entity - like in our app - then, for example, your Put operation already uses a state processor called PersistProcessor! The Post operation also uses that, and Delete has one called RemoveProcessor.

State processors are cool. After the sent data is deserialized onto the object, we... need to do something! Most of the time, that "something" is: save the object to the database. And that's precisely what PersistProcessor does! Yea, our entity changes are saved to the database entirely thanks to that built-in state processor!

Creating the Custom State Processor

So here's the plan: we're going to hook into the state processor system and add our own. Step one, run a new command from API Platform:

php ./bin/console make:state-processor

Let's call it UserHashPasswordProcessor. Perfect.

Spin over, go into src/, open the new State/ directory and check out UserHashPasswordStateProcessor:

... lines 1 - 2
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
class UserHashPasswordStateProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
// Handle the state
}
}

It's delightfully simple: API platform will call this method, pass us data, tell us which operation is happening... and a few other things. Then... we just do whatever we want. Send emails, save things to the database, or RickRoll someone watching a screencast!

Activating this processor is simple in theory. We could go to the Post operation, add a processor option and set it to our service id: UserHashPasswordStateProcessor::class.

Unfortunately... if we did that, it would replace the PersistProcessor that it's using now. And... we don't want that: we want our new processor to run... and then also the existing PersistProcessor. But... each operation can only have one processor.

Setting up Decoration

No worries! We can do this by decorating PersistProcessor. Decoration always follows the same pattern. First, add a constructor that accept an argument with the same interface as our class: private ProcessorInterface and I'll call it $innerProcessor:

... lines 1 - 5
use ApiPlatform\State\ProcessorInterface;
... lines 7 - 9
class UserHashPasswordStateProcessor implements ProcessorInterface
{
public function __construct(private ProcessorInterface $innerProcessor)
{
}
... lines 15 - 21
}

After I add a dump() to see if this is working, we'll do step 2: call the decorated service method: $this->innerProcessor->process() passing $data, $operation, $uriVariables and... yes, $context:

... lines 1 - 9
class UserHashPasswordStateProcessor implements ProcessorInterface
{
... lines 12 - 15
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
dump('ALIVE!');
$this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}

Love it: our class is set up for decoration. Now we need to tell Symfony to use it. Internally, PersistProcessor from API Platform is a service. We're going to tell Symfony that whenever anything needs that PersistProcessor service, it should be passed our service instead... but also that Symfony should pass us the original PersistProcessor.

To do that, add #[AsDecorator()] and pass the id of the service. You can usually find this in the documentation, or you can use the debug:container command to search for it. The docs say it's api_platform.doctrine.orm.state.persist_processor:

Tip

Instead of this long string, API Platform also creates an "alias service" to the core processor's class name. This allows you to use:

use ApiPlatform\Doctrine\Common\State\PersistProcessor;
// ...
#[AsDecorator(PersistProcessor::class)]

... lines 1 - 6
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
class UserHashPasswordStateProcessor implements ProcessorInterface
{
... lines 12 - 21
}

Decoration done! We're not doing anything yet, but let's see if it hits our dump! Run the test:

symfony php bin/phpunit tests/Functional/UserResourceTest.php

And... there it is! It's still a 500, but it is using our processor!

Adding the Hashing Logic

Now we can get to work. Because of how we did the service decoration, our new processor will be called whenever any entity is processed... whether it's a User, DragonTreasure or something else. So, start by checking if $data is an instanceof User... and if $data->getPlainPassword()... because if we're editing a user, and no password is sent, no need for us to do anything:

... lines 1 - 11
class UserHashPasswordStateProcessor implements ProcessorInterface
{
... lines 14 - 17
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if ($data instanceof User && $data->getPlainPassword()) {
... line 21
}
$this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}

By the way, the official documentation for decorating state processors is slightly different. It looks more complex to me, but the end result is a processor that's only called for one entity, not all of them.

To hash the password, add a second argument to the constructor: private UserPasswordHasherInterface called $userPasswordHasher:

... lines 1 - 8
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
... lines 10 - 11
class UserHashPasswordStateProcessor implements ProcessorInterface
{
public function __construct(private ProcessorInterface $innerProcessor, private UserPasswordHasherInterface $userPasswordHasher)
{
}
... lines 17 - 25
}

Below, say $data->setPassword() set to $this->userPasswordHasher->hashPassword() passing it the User, which is $data and the plain password: $data->getPlainPassword():

... lines 1 - 11
class UserHashPasswordStateProcessor implements ProcessorInterface
{
... lines 14 - 17
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if ($data instanceof User && $data->getPlainPassword()) {
$data->setPassword($this->userPasswordHasher->hashPassword($data, $data->getPlainPassword()));
}
$this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}

And this all happens before we call the inner processor that actually saves the object.

Let's try this thing! Run that test:

symfony php bin/phpunit tests/Functional/UserResourceTest.php

Victory! After creating a user in our API, we can then log in as that user.

User.eraseCredentials()

Oh, and it's minor, but once you have a plainPassword property, inside of User, there's a method called eraseCredentials(). Uncomment $this->plainPassword = null:

... lines 1 - 67
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 70 - 186
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
$this->plainPassword = null;
}
... lines 192 - 292
}

This makes sure that if the object is serialized into the session, the sensitive plainPassword is cleared first.

Next: let's fix some validation issues via validationGroups and discover something special about the Patch operation.

Leave a comment!

7
Login or Register to join the conversation
Sebastian-K Avatar
Sebastian-K Avatar Sebastian-K | posted 1 month ago

Is there a way to process additional fields from the request?
For example, for the registration, I send, email, password and firstname.
email and password goes to the User entity and the firstname in the Profile entity (that's created in a StateProcessor, but there, I don't have access to the original POST request/data

Reply

Hey @Sebastian-K!

Hmmm. How have you set things up so that the "registration" endpoint has a firstName field but without User having a firstName property? Usually I WOULD have this as a property on User, or I might make a DTO for this specific operation if you've got things split up.

But anyway, this is an interesting problem! The JSON is ready from the request and passed directly to the serializer here: https://github.com/api-platform/core/blob/main/src/Symfony/EventListener/DeserializeListener.php#L98-L101

The problem is that, if your "resource class" for this operation is User and it doesn't have a firstName property, then that field from the JSON is simply ignored. I think the only way to get the firstName field would be to grab the $request->getContent() and json_decode() it manually. But... I really hope we can find a better way :).

Cheers!

Reply
Fedale Avatar

Hi team, my question is: if I use Symfony with Api Platform and Easy Admin, where I have to write "unified" logic to hash the password for both applications?
I think that in that case a listener/subscriber is better, right?

Reply

Hey @Fedale!

That's a great question. I can think of 2 options, and they're both totally fine imo:

  • 1) A doctrine listener/subscriber as you were mentioning. These are a bit magic and they're hard to debug if you make a mistake while setting them up (they might just "not work" but no errors). But they work great and are very robust (once you get them working, it's not going to suddenly break).
  • 2) Duplicate the hashing logic: do it in ApiPlatform like we do, then do it in EasyAdmin by, for example, overriding persistEntity() in your controller. Duplication sounds lame... but password hashing logic is already SO simple (it's just 1 line basically) that you are not really duplicating much.

Cheers!

Reply
Tobias-B Avatar
Tobias-B Avatar Tobias-B | posted 4 months ago

Does it really make sense to set up decoration for the UserHashPasswordProcessor via #[AsDecorator()]? As I understand it the decorating service is then involved in every call of the PersistProcessor?

In the API Platform docs they use a "bind" in the services to bind the $persistProcessor as an argument to the "UserPasswordHasher".
This way I guess it is only decorating the service when it is used (e.g. defining the "processor" on operation level)...

Reply
David-S Avatar
David-S Avatar David-S | Tobias-B | posted 7 days ago | edited

I'm building this tutorial not in the project but in a custom bundle.
Since it took me some time to find the solution for my case, I'd like to post it here for others that might struggle with that.

I did NOT set the #[AsDecorator(PersistProcessor::class)] attribute in UserHashPasswordProcessor

In the User Entity I added processor: UserHashPasswordProcessor::class to Put, Post and Patch.
Example:

new Put(
            security: 'is_granted("ROLE_USER_EDIT")',
            processor: UserHashPasswordProcessor::class
)

In the bundles services.xml I added:

<service id="Acme\MyBundle\State\UserHashPasswordProcessor" autowire="true" autoconfigure="true">
            <bind key="$processor" id="api_platform.doctrine.orm.state.persist_processor" type="service"/>
</service>

Since this Processor is called from the User Entity now, I also modified the process method in UserHashPasswordProcessor a litte:

public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
    {
        if ($data->getPlainPassword()) {
            $hashedPassword = $this->userPasswordHasher->hashPassword($data, $data->getPlainPassword());
            $data->setPassword($hashedPassword);
            $data->eraseCredentials();
        }

        $this->processor->process($data, $operation, $uriVariables, $context);
    }

If this isn't smart, please correct me :-)

Reply

Hey @Tobias-B!

Your thinking on this is absolutely correct. For me, it was a trade-off between complexity (the API Platform official way is more complex) vs potential performance problems. So, the final decision is subjective, but since PersistProcessor is only called during POST/PUT/PATCH operations and it will only be called once (I would be more concerned if PersistProessor were called many times during a single request) and the logic inside of UserHashPasswordProcessor is really simple in those cases (if not User, it exits immediately), I think the performance issue is non-material. So, I went for simplicity :). But I think the other approach is 110% valid - so you can choose your favorite.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice