SymfonyCasts / reset-password-bundle

Need a killer reset password feature for your Symfony? Us too!
https://symfonycasts.com
MIT License
478 stars 67 forks source link

Does this bundle support API platform? #128

Open michaelHottomali opened 4 years ago

sh41 commented 4 years ago

I've made progress with API platform by using the maker install and then making some modifications.

For the reset request I've used the messenger integration and a handler.

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ApiResource(
 *     messenger=true,
 *     collectionOperations={
 *         "post"={"status"=202, "path"="/reset_password/request.{_format}"}
 *     },
 *     itemOperations={},
 *     output=false)
 *
 */
final class ResetPasswordRequestInput
{

    /**
     * @var string email
     * @Assert\NotBlank
     */
    public $email;

}

and

<?php

namespace App\Messenger\Handler;

use App\Entity\ResetPasswordRequestInput;
use App\Repository\ResetPasswordRequestRepository;
use App\Repository\UserRepository;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Component\Mime\Address;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;

class ResetPasswordRequestHandler implements MessageHandlerInterface
{

    /**
     * @var UserRepository
     */
    private UserRepository $userRepository;
    /**
     * @var ResetPasswordRequestRepository
     */
    private ResetPasswordRequestRepository $resetPasswordRequestRepository;
    /**
     * @var ResetPasswordHelperInterface
     */
    private ResetPasswordHelperInterface $resetPasswordHelper;
    /**
     * @var MailerInterface
     */
    private MailerInterface $mailer;

    public function __construct(UserRepository $userRepository, ResetPasswordRequestRepository $resetPasswordRequestRepository, ResetPasswordHelperInterface $resetPasswordHelper, MailerInterface $mailer)
    {
        $this->userRepository = $userRepository;
        $this->resetPasswordRequestRepository = $resetPasswordRequestRepository;
        $this->resetPasswordHelper = $resetPasswordHelper;
        $this->mailer = $mailer;
    }

    public function __invoke(ResetPasswordRequestInput $resetPasswordRequestInput)
    {
        $user = $this->userRepository->findOneByEmail($resetPasswordRequestInput->email);

        if (!$user) {
            return;
        }

        try {
            $resetToken = $this->resetPasswordHelper->generateResetToken($user);
        } catch (ResetPasswordExceptionInterface $e) {
            return;
        }

        $email = (new TemplatedEmail())
            ->from(new Address('no-reply@example.com', 'Password reset'))
            ->to($user->getEmail())
            ->subject('Your password reset request')
            ->htmlTemplate('reset_password/email.html.twig')
            ->context([
                          'resetToken' => $resetToken,
                          'tokenLifetime' => $this->resetPasswordHelper->getTokenLifetime(),
                      ])
        ;

        $this->mailer->send($email);
    }
}

Then for the actual reset I used a DTO and DataTransformer. On my User entity I added a post_change_password custom method with custom input class of ResetPasswordChangeInput

/**
 * @ApiResource(
 *     collectionOperations={
 *        "post"={"security"="is_granted('IS_AUTHENTICATED_ANONYMOUSLY')"},
 *       "post_change_password"={
 *           "method"="POST",
 *           "path"="/reset_password/change",
 *           "input"=ResetPasswordChangeInput::class,
 *           "output"=false,
 *           "status"=201
 *     },
//.... etc
<?php

namespace App\Dto;

use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

class ResetPasswordChangeInput
{

    /**
     * @var string
     * @Assert\NotBlank()
     * @Groups({"user:write"})
     */
    public $token;

    /**
     * @var string
     * @Groups({"user:write"})
     * @Assert\Length(min=8, max=255, allowEmptyString = false )
     */
    public $plainPassword;

}

and then have a DataTransformer that deals with the actual password change:

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\ResetPasswordChangeInput;
use App\Entity\User;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;

class ResetPasswordChangeDataTransformer implements DataTransformerInterface
{

    /**
     * @var ResetPasswordHelperInterface
     */
    private ResetPasswordHelperInterface $resetPasswordHelper;

    public function __construct(ResetPasswordHelperInterface $resetPasswordHelper)
    {
        $this->resetPasswordHelper = $resetPasswordHelper;
    }

    /**
     * @inheritDoc
     */
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return User::class === $to && ResetPasswordChangeInput::class === ($context['input']['class'] ?? null);
    }

    /**
     * @param ResetPasswordChangeInput $dto
     * @param string $to
     * @param array $context
     */
    public function transform($dto, string $to, array $context = [])
    {
        $token = $dto->token;

        try {
            $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
        } catch (ResetPasswordExceptionInterface $e) {
            throw new BadRequestHttpException(
                sprintf(
                    'There was a problem validating your reset request - %s',
                    $e->getReason()
                )
            );
        }
        // do our own validation here so that the token doesn't get invalidated on password validation failure
        $violations = $this->validator->validate($dto);
        if (count($violations) > 0) {
            throw new ValidationException($violations);
        }
        if ($user instanceof User) {
            $this->resetPasswordHelper->removeResetRequest($token);
            $user->setPlainPassword($dto->plainPassword);
        }
        return $user;
    }
}

I removed the Controller, FormTypes and some of the templates that were made by the maker as I don't need them, but if you wanted to retain the ability to do resets via API or forms I imagine that you could keep them in place.

weaverryan commented 4 years ago

Thanks for sharing :). I haven't looked at your code in detail, but the Messenger integration is probably what I would have chosen too. I think this is actually something we should put into MakerBundle or at least the docs here. Basically, you should be able to choose that you want to use this bundle in "API mode" and get code generated. I would welcome a PR for that.

weaverryan commented 4 years ago

I'm also going to cover this at some point soonish in a SymfonyCasts tutorial. If anyone wants to turn this into a MakerBundle PR, you can ping me on the Symfony Slack so we can chat what that should look like so that you don't lose time. I would love if someone wanted to take that challenge on!

Snowbaha commented 3 years ago

Thank you for the code @sh41 !

It's missing de little part on the DataTransformer with the service validator:

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use ApiPlatform\Core\Validator\Exception\ValidationException;
use ApiPlatform\Core\Validator\ValidatorInterface;
use App\Dto\ResetPasswordChangeInput;
use App\Entity\User;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;

class ResetPasswordChangeDataTransformer implements DataTransformerInterface
{
    private ValidatorInterface $validator; // fix here
    private ResetPasswordHelperInterface $resetPasswordHelper;

    public function __construct(ResetPasswordHelperInterface $resetPasswordHelper, ValidatorInterface $validator)
    {
        $this->resetPasswordHelper = $resetPasswordHelper;
        $this->validator = $validator;
    }

    /**
     * @inheritDoc
     */
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return User::class === $to && ResetPasswordChangeInput::class === ($context['input']['class'] ?? null);
    }

    /**
     * @param ResetPasswordChangeInput $dto
     * @param string $to
     * @param array $context
     */
    public function transform($dto, string $to, array $context = [])
    {
        $token = $dto->token;

        try {
            $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
        } catch (ResetPasswordExceptionInterface $e) {
            throw new BadRequestHttpException(
                sprintf(
                    'There was a problem validating your reset request - %s',
                    $e->getReason()
                )
            );
        }
        // do our own validation here so that the token doesn't get invalidated on password validation failure
        $violations = $this->validator->validate($dto);

        if (null !== $violations) { // fix here
            throw new ValidationException($violations);
        }
        if ($user instanceof User) {
            $this->resetPasswordHelper->removeResetRequest($token);
            $user->setPlainPassword($dto->plainPassword);
        }
        return $user;
    }
}

@weaverryan Indeed a maker for this would be nice ^^ I am trying to adapt your bundles reset-password and email-verify with Api platform but not so easy even if I just finished the part 3 with your tuto ^^

Tempest99 commented 3 years ago

Hi all, I've tried to implement this, but I get a 400 error in ResetPasswordRequestInput as $email is null, in the profiler, I can see that the raw content is {"email":"me@me.com"} but the Post Parameters are empty, I do have the JWT bundle installed as well, so I'm not sure if I have missed something or that I need to set something else in security.yaml, tbh I'm a little lost and obviously, I need someone smarter than me to point me in the right direction. ;-)

laferte-tech commented 3 years ago

Hello @Tempest99 , i had the same problem like you. For me i added the denormalization group and it works:

    /**
     * @var string email
     * @Assert\NotBlank
     * @Groups({"resetpasswordrequestinput:collection:post"})
     */
    public string $email;

Also with Api Platform 2.6, i needed to add:

    /**
     * @var integer|null
     */
    public $id;

to make it work to send the email. I didn't go further for now because i need to follow the end of the part 3 of Api Platform tutorial to undestand DTO.

Tempest99 commented 3 years ago

Hi @GrandOurs35, wow thats neater compared to what I finally did, I meant to post up what I did, but I got way too busy with my daytime job. My whole api is locked down, and like you still working through the tutorials which are amazing. So what I did was... in security.yaml under access_control I have

- { path: ^/api/reset_password, roles: IS_AUTHENTICATED_ANONYMOUSLY }

and I have a firewall for it as well

reset_password_request:
            pattern: ^/api/reset_password
            stateless: true
            lazy: true

then in the ResetPasswordRequestInput.php mine looks like this

namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\Validator\Constraints as Assert;
/**
 * @ApiResource(
 *     messenger=true,
 *     collectionOperations={
 *         "post"={"status"=202,
 *                  "path"="/reset_password/request.{_format}",
 *                  "is_granted('ROLE_PUBLIC')"
 *          }
 *     },
 *     itemOperations={},
 *     output=false)
 * @IsGranted("IS_AUTHENTICATED_ANONYMOUSLY")
 * @IsGranted("ROLE_PUBLIC")
 */
final class ResetPasswordRequestInput
{
    /**
     * @var string emailaddress
     * @Assert\NotBlank
     *  @IsGranted("ROLE_PUBLIC")
     */
    public $emailaddress;
}

I doubt I've missed anything else I did, maybe some of it's overkill, but until I fully understand things better, I won't be refactoring anything, but it works :-)

jrushlow commented 3 years ago

Just a heads up, I'm working on the implementation for this in MakerBundle as we speak... I'm committing myself to have a PR up later today! I'm simultaneously working on a PR to update our readme here in this repo to explain how to integrate reset-password w/ Api Platform..

ericovasconcelos commented 3 years ago

The password endpoints are functional, but the react-admin frontend that reads the Hydra generated document is complaining that the ResetPasswordRequestInput endpoint does not provide a GET item operation declared. I do believe that is the expected behavior, but is there a way to help Hydra documentation to fullfill this requirement?

ericovasconcelos commented 3 years ago

The password endpoints are functional, but the react-admin frontend that reads the Hydra generated document is complaining that the ResetPasswordRequestInput endpoint does not provide a GET item operation declared. I do believe that is the expected behavior, but is there a way to help Hydra documentation to fullfill this requirement?

I've added the reset request operation to User entity instead of using a new ApiResource for it. This made the password request and change operations to be related to the User resource on the API.

Then migrate the code from ResetPasswordRequestInput from an entity to become a new DTO. And then add a DataTransformer to dispatch the message of ResetPasswordRequestInput to be handled by the message handler. (remember to change the handler to reference the ResetPasswordRequestInput from Dto namespace instead of older Entity

File: User.php

...
/**
 * @ApiResource( 
 *      attributes={"security"="is_granted('ROLE_ADMIN')"},
 *      collectionOperations={
 *          "post_reset_password"={
 *              "method"="POST",
 *              "status"=202, 
 *              "messenger"="input", 
 *              "security"="is_granted('IS_AUTHENTICATED_ANONYMOUSLY')",
 *              "input"=ResetPasswordRequestInput::class,
 *              "output"=false,
 *              "path"="/reset_password/request.{_format}",
 *              },
 *          "get","post",

File: App\Dto\ResetPasswordRequestInput.php

<?php

namespace App\Dto;

use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

class ResetPasswordRequestInput 
{

    /**
     * @var string
     * @Assert\NotBlank()
     */
    public $email;

}

File: App\DataTransformer\ResetPasswordRequestDataTransformer.php

<?php

namespace App\DataTransformer;

use App\Entity\User;
use App\Dto\ResetPasswordRequestInput;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;

class ResetPasswordRequestDataTransformer implements DataTransformerInterface
{

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        $this->data = $data;
        return User::class === $to && ResetPasswordRequestInput::class === ($context['input']['class'] ?? null);
    }

    /**
     * @param ResetPasswordChangeInput $dto
     * @param string $to
     * @param array $context
     */
    public function transform($dto, string $to, array $context = [])
    {
        $dto->email = $this->data['email'];
        return $dto;       
    }
}