Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Procesadores de Estado: Hashing de la contraseña de usuario

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

Cuando un cliente de la API crea un usuario, envía un campo password, que se establece en la propiedad plainPassword. Ahora tenemos que aplicar el hash a esa contraseña antes de que Userse guarde en la base de datos. Como demostramos al trabajar con Foundry, hashear una contraseña es sencillo: coge el servicio UserPasswordHasherInterface y llama a un método sobre él:

... 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
}

Pero para conseguirlo, necesitamos un "gancho" en la API Platform: necesitamos alguna forma de ejecutar código después de que nuestros datos se deserialicen en el objeto User, pero antes de que se guarden.

En nuestro tutorial sobre la API Platform 2, utilizamos para ello una escucha Doctrine, que seguiría funcionando. Sin embargo, tiene algunos aspectos negativos, como ser supermágico -es difícil depurar si no funciona- y tienes que hacer algunas cosas raras para asegurarte de que se ejecuta al editar la contraseña de un usuario.

Hola Procesadores de Estado

Afortunadamente, en API Platform 3 tenemos una nueva y brillante herramienta que podemos aprovechar: se llama procesador de estado. Y de hecho, ¡nuestra clase User ya utiliza un procesador de estado!

Encuentra la Guía de actualización de la API Platform 2 a la 3... y busca procesador. Veamos... aquí está. Tiene una sección llamada proveedores y procesadores. Hablaremos de proveedores más adelante.

Según esto, si tienes una clase ApiResource que es una entidad -como en nuestra aplicación-, entonces, por ejemplo, tu operación Put ya utiliza un procesador de estado llamado PersistProcessor La operación Post también lo utiliza, y Delete tiene uno llamado RemoveProcessor.

Los procesadores de estado son geniales. Después de que los datos enviados se deserialicen en el objeto, nosotros... ¡necesitamos hacer algo! La mayoría de las veces, ese "algo" es: guardar el objeto en la base de datos. ¡Y eso es precisamente lo que hace PersistProcessor! ¡Sí, nuestros cambios de entidad se guardan en la base de datos por completo gracias a ese procesador de estado incorporado!

Creación del procesador de estado personalizado

Así que éste es el plan: vamos a engancharnos al sistema de procesadores de estado y añadir el nuestro propio. Primer paso: ejecuta un nuevo comando desde la API Platform:

php ./bin/console make:state-processor

Llamémoslo UserHashPasswordProcessor. Perfecto.

Gira, entra en src/, abre el nuevo directorio State/ y echa un vistazo aUserHashPasswordStateProcessor:

... 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
}
}

Es deliciosamente sencillo: API Platform llamará a este método, nos pasará datos, nos dirá qué operación está ocurriendo... y algunas cosas más. Luego... hacemos lo que queramos. Enviar correos electrónicos, guardar cosas en la base de datos, ¡o RickRollar a alguien viendo un screencast!

Activar este procesador es sencillo en teoría. Podríamos ir a la operación Post, añadir una opción processor y configurarla con nuestro id de servicio: UserHashPasswordStateProcessor::class.

Por desgracia... si hiciéramos eso, sustituiría al PersistProcessor que está utilizando ahora. Y... no queremos eso: queremos que se ejecute nuestro nuevo procesador... y también el existente PersistProcessor. Pero... cada operación sólo puede tener un procesador.

Configurar la decoración

¡No te preocupes! Podemos hacerlo decorando PersistProcessor. La decoración sigue siempre el mismo patrón. Primero, añade un constructor que acepte un argumento con la misma interfaz que nuestra clase: private ProcessorInterface y lo llamaré $innerProcessor:

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

Después de añadir un dump() para ver si funciona, haremos el paso 2: llamar al método de servicio decorado: $this->innerProcessor->process() pasando $data, $operation,$uriVariables y... sí, $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);
}
}

Me encanta: nuestra clase está preparada para la decoración. Ahora tenemos que decirle a Symfony que la utilice. Internamente, PersistProcessor de API Platform es un servicio. Vamos a decirle a Symfony que siempre que algo necesite ese servicio PersistProcessor, le pase nuestro servicio en su lugar... pero también que Symfony nos pase el PersistProcessor original.

Para ello, añade #[AsDecorator()] y pásale el id del servicio. Normalmente puedes encontrarlo en la documentación, o puedes utilizar el comando debug:container para buscarlo. La documentación dice que es api_platform.doctrine.orm.state.persist_processor:

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

¡Decoración realizada! Todavía no estamos haciendo nada, ¡pero vamos a ver si llega a nuestro volcado! Ejecuta la prueba:

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

Y... ¡ahí está! Sigue siendo un 500, ¡pero está utilizando nuestro procesador!

Añadir la lógica Hashing

Ahora podemos ponernos manos a la obra. Debido a cómo hicimos la decoración del servicio, nuestro nuevo procesador será llamado siempre que se procese cualquier entidad... ya sea un User, un DragonTreasure o cualquier otra cosa. Así que, empieza por comprobar si $data es un instanceof User... y si $data->getPlainPassword()... porque si estamos editando un usuario, y no se envía ningún password, no hace falta que hagamos nada:

... 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);
}
}

Por cierto, la documentación oficial de los procesadores de estados de decoración es ligeramente diferente. A mí me parece más complejo, pero el resultado final es un procesador que sólo se llama para una entidad, no para todas.

Para hacer hash de la contraseña, añade un segundo argumento al constructor:private UserPasswordHasherInterface llamado $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
}

A continuación, digamos que $data->setPassword() se establece en $this->userPasswordHasher->hashPassword()pasándole el User, que es $data y la contraseña simple: $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);
}
}

Y todo esto ocurre antes de que llamemos al procesador interno que guarda realmente el objeto.

¡Vamos a probar esto! Ejecuta la prueba:

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

¡Victoria! Después de crear un usuario en nuestra API, podemos iniciar sesión como ese usuario.

Usuario.borrarCredenciales()

Ah, y es algo sin importancia, pero una vez que tienes una propiedad plainPassword, dentro de User, hay un método llamado eraseCredentials(). Descomenta $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
}

Esto asegura que si el objeto se serializa en la sesión, se borre primero el plainPassword sensible.

A continuación: arreglemos algunos problemas de validación mediante validationGroups y descubramos algo especial sobre la operación Patch.

Leave a comment!

7
Login or Register to join the conversation
Sebastian-K Avatar
Sebastian-K Avatar Sebastian-K | posted hace 1 mes

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 hace 4 meses

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 hace 7 días | 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