Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Does this bundle support API platform? #128

Open
michaelHottomali opened this issue Sep 2, 2020 · 10 comments
Open

Does this bundle support API platform? #128

michaelHottomali opened this issue Sep 2, 2020 · 10 comments
Labels
enhancement New feature or request maker bundle related Cause is related to maker bundle

Comments

@michaelHottomali
Copy link

No description provided.

@sh41
Copy link

sh41 commented Oct 4, 2020

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('[email protected]', '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
Copy link
Contributor

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
Copy link
Contributor

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
Copy link

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 ^^

@jrushlow jrushlow added enhancement New feature or request maker bundle related Cause is related to maker bundle labels Dec 18, 2020
@Tempest99
Copy link

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":"[email protected]"} 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
Copy link

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
Copy link

Tempest99 commented Jan 29, 2021

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
Copy link
Collaborator

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
Copy link

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
Copy link

ericovasconcelos commented Apr 23, 2021

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request maker bundle related Cause is related to maker bundle
Projects
None yet
Development

No branches or pull requests

8 participants