DevMoutarde
DevMoutarde

Reputation: 615

How do I modify a value before sending it to DB using a serializer/deserializer?

I am trying to set up a deserializer as well as a custom denormalizer in the middle so I can hash the user password before persisting the whole entity in the DB.

To be clear, I would like to deserialize my $request into the User class, while modifying some properties in the process. I know I can use some de/normalizers and serializer/deserializer but I'm confused on how to apply them.

Right now I am using the following code to deserialize my $request object into a User instance :

$serializer->deserialize(
    $request->getContent(), 
    User::class,
    'json', 
    [ AbstractNormalizer::OBJECT_TO_POPULATE => $user]
);

What should I do ? I have set up this custom denormalizer as follows, I tried to register it automatically, as well as specifically registering it in my services.yaml, but it does not seem to be used when I deserialize my content (no change whatsoever, not even my die dump happening). It is, however, registered in my services list.

<?php

namespace App\Serializer;

use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

class PasswordNormalizer implements DenormalizerInterface
{
public function __construct(
    #[Autowire(service: 'serializer.denormalizer.array')]
    private readonly DenormalizerInterface $denormalizer,
    private UserPasswordHasherInterface $passwordHasher
) {
    $this->passwordHasher = $passwordHasher;
}

public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
    if ($type === User::class && isset($data['password'])) {
        $plainPassword = $data['password'];
        
        unset($data['password']);
        
        // Denormalize to create User object
        $user = $this->denormalizer->denormalize($data, $type, $format, $context);
        
        // Hash
        $hashedPassword = $this->passwordHasher->hashPassword($user, $plainPassword);
        $user->setPassword($hashedPassword);
        
        return $user;
    }

    return $this->denormalizer->denormalize($data, $type, $format, $context);
}

public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
{
    // dd($data, $type, $format, $context);
    return $type === User::class;
}

public function getSupportedTypes(?string $format): array
{
    return [
        '*' => false
    ];
}

}

Not sure what service I should autowire, I've tried many things and it makes no difference.

Upvotes: 0

Views: 110

Answers (1)

Bademeister
Bademeister

Reputation: 866

A custom Denormalizer is not the best choice. I would do it with a #ValueResolver or #MapEntity with ValueResolver.

Custom ValueResolver

UserValueResolver

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Serializer\SerializerInterface;

class UserValueResolver implements ValueResolverInterface
{
    public function __construct(
        private UserPasswordHasherInterface $passwordHasher,
        private SerializerInterface $serializer
    ) {
    }

    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        $user = $this->serializer->deserialize($request->getContent(), User::class, 'json');
        $user->setPassword($this->passwordHasher->hashPassword($user, $user->getPassword()));
        return [$user];
    }
}

Action

#[Route('/user', name: 'user_add', methods: ['POST'])]
public function userAdd(
    #[ValueResolver(resolver: UserValueResolver::class)] User $user
): Response {
    // User password is hashed
}

FunctionalTest UserValueResolver

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Serializer\SerializerInterface;

class UserValueResolverTest extends KernelTestCase
{
    public function testResolve()
    {
        self::bootKernel();

        $requestMock= $this->createMock(Request::class);
        $requestMock->method('getContent')->willReturn('{
            "username": "john",
            "password": "secretPassword1234"
        }');

        $argumentMetadataMock= $this->createMock(ArgumentMetadata::class);

        $resolver = new UserValueResolver(
            self::getContainer()->get(UserPasswordHasherInterface::class),
            self::getContainer()->get(SerializerInterface::class)
        );

        $resolved = $resolver->resolve(
            $requestMock,
            $argumentMetadataMock
        );

        $this->assertSame('bcrypt', password_get_info($resolved[0]->getPassword())['algoName']);
    }
}

Custom Denormalizer

But this is how it could work with your own Denormalizer. Processing the whole User object in the Denormalizer is cumbersome if you want to have a User object after the process.

Therefore the custom type HashedPasswordType is used.

HashedPasswordTypeDenormalizer

use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

class HashedPasswordTypeDenormalizer implements DenormalizerInterface
{
    public function __construct(
        private UserPasswordHasherInterface $passwordHasher
    ) {
    }

    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = [])
    {
        $user = new class implements PasswordAuthenticatedUserInterface {
            public string $password;
            public function getPassword(): ?string
            {
                return $this->password;
            }
        };
        $user->password = $data;

        return new HashedPasswordType($this->passwordHasher->hashPassword($user, $user->getPassword()));
    }

    public function supportsDenormalization(mixed $data, string $type, ?string $format = null): bool
    {
        return $type === HashedPasswordType::class;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [
            HashedPasswordType::class => false // not cacheable
        ];
    }
}

HashedPasswordType

class HashedPasswordType
{
    public function __construct(private readonly ?string $value = null) {}

    public function getValue(): ?string
    {
        return $this->value;
    }
}

User Entity

To avoid having to configure or implement anything for Doctrine, a dummy property is used for the Denormalizer process.

The HashedPasswordType custom type is used for this property in order to use the HashedPasswordTypeDenormalizer.

When the Denormalizer process stores the value with setHashedPassword(), it is stored in the password property and can be persisted with Doctrine.

If you are using DoctrineMigration, the hashedPassword field will be included in the migration. You will need to see how to exclude it. I can not find anything like exclude, ignore or similar in the list.

use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\SerializedName;

class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[Column(...)]
    public string $username;

    #[Column(...)]
    public string $password;

    #[SerializedName('password')]
    public HashedPasswordType $hashedPassword;

    public function setHashedPassword(HashedPasswordType $hashedPassword): void
    {
        $this->password = $hashedPassword->getValue();
    }

    public function getUsername(): string
    {
        return $this->username;
    }

    // other setter and getter
}

Functional Test

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\SerializerInterface;

class HashedPasswordTypeDenormalizerTest extends KernelTestCase
{
    public function testDenormalize(): void
    {
        self::bootKernel();

        $payload = '{
            "username": "john",
            "password": "secretPassword1234"
        }';

        /** @var $serializer SerializerInterface  */
        $serializer = self::getContainer()->get(SerializerInterface::class);

        $user = $serializer->deserialize($payload, User::class, 'json');

        $this->assertSame('bcrypt', password_get_info($user->getPassword())['algoName']);
    }
}

Upvotes: 1

Related Questions