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

[API] add docs to show API Platform implementation. #147

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,280 @@ Feel free to open an issue for questions, problems, or suggestions with our bund
Issues pertaining to Symfony's Maker Bundle, specifically `make:reset-password`,
should be addressed in the [Symfony Maker repository](https://github.com/symfony/maker-bundle).

## API Usage Example

If you're using [API Platform](https://api-platform.com/), this example will
demonstrate how to implement ResetPasswordBundle into the API.

```php
// src/Entity/ResetPasswordRequest

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Dto\ResetPasswordInput;
use App\Repository\ResetPasswordRequestRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\UuidV4;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;

/**
* @ApiResource(
* security="is_granted('IS_ANONYMOUS')",
jrushlow marked this conversation as resolved.
Show resolved Hide resolved
* input=ResetPasswordInput::class,
* output=false,
* shortName="reset-password",
* collectionOperations={
* "post" = {
* "denormalization_context"={"groups"={"reset-password:post"}},
* "status" = 202,
* "validation_groups"={"postValidation"},
* },
* },
* itemOperations={
* "put" = {
* "denormalization_context"={"groups"={"reset-password:put"}},
* "validation_groups"={"putValidation"},
* },
* },
* )
*
* @ORM\Entity(repositoryClass=ResetPasswordRequestRepository::class)
*/
class ResetPasswordRequest implements ResetPasswordRequestInterface
{
use ResetPasswordRequestTrait;

/**
* @ORM\Id
* @ORM\Column(type="string", unique=true)
*/
private string $id;

/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=false)
*/
private User $user;

public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
{
$this->id = new UuidV4();
$this->user = $user;
$this->initialize($expiresAt, $selector, $hashedToken);
}

public function getId(): string
{
return $this->id;
}

public function getUser(): User
{
return $this->user;
}
}
```

Because the `ResetPasswordHelper::generateResetToken()` method is responsible for
creating and persisting a `ResetPasswordRequest` object after the reset token has been
generated, we can't call `POST /api/reset-passwords` with `['email' => '[email protected]']`.

We'll create a Data Transfer Object (`DTO`) first, that will be used by a Data Persister
to generate the actual `ResetPasswordRequest` object from the email address provided
in the `POST` api call.

```php
<?php

namespace App\Dto;

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

/**
* @author Jesse Rushlow <[email protected]>
*/
class ResetPasswordInput
{
/**
* @Assert\NotBlank(groups={"postValidation"})
* @Assert\Email(groups={"postValidation"})
* @Groups({"reset-password:post"})
*/
public string $email;

/**
* @Assert\NotBlank(groups={"putValidation"})
* @Groups({"reset-password:put"})
*/
public string $token;

/**
* @Assert\NotBlank(groups={"putValidation"})
* @Groups({"reset-password:put"})
*/
public string $plainTextPassword;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be easier to split this into 2 DTO objects - something like RequestResetPasswordInput and ResetPasswordInput. Then you can configure the input on each, specific operation i think (instead of having the input= on the top-level.

Another option would be to create these 2 DTO's and make THEM each their own @ApiResource... each with 1 operation. I'm not sure if having ResetPasswordRequest as the @ApiResource is doing us any favors, as we never use it as the input or output. That would be my biggest potential feedback on this otherwise awesome effort. input and output DTO's are kind of an edge-case feature in API Platform... so if we can find a clean way to do this without them, that might ideal.

}
```

```php
<?php

namespace App\DataProvider;

use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use App\Entity\ResetPasswordRequest;
use App\Entity\User;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;

class ResetPasswordDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface
{
private ResetPasswordHelperInterface $resetPasswordHelper;

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

public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return ResetPasswordRequest::class === $resourceClass && 'put' === $operationName;
}

public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []): User
{
if (!is_string($id)) {
throw new NotFoundHttpException('Invalid token.');
}

$user = $this->resetPasswordHelper->validateTokenAndFetchUser($id);

if (!$user instanceof User) {
throw new NotFoundHttpException('Invalid token.');
}

$this->resetPasswordHelper->removeResetRequest($id);

return $user;
}
}
```

Finally we'll create a Data Persister that is responsible for using the
`ResetPasswordHelper::class` to generate a `ResetPasswordRequest` and email the
token to the user.

```php
<?php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\Dto\ResetPasswordInput;
use App\Entity\User;
use App\Message\SendResetPasswordMessage;
use App\Repository\UserRepository;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;

/**
* @author Jesse Rushlow <[email protected]>
*/
class ResetPasswordDataPersister implements ContextAwareDataPersisterInterface
{
private UserRepository $userRepository;
private ResetPasswordHelperInterface $resetPasswordHelper;
private MessageBusInterface $messageBus;
private UserPasswordEncoderInterface $userPasswordEncoder;

public function __construct(UserRepository $userRepository, ResetPasswordHelperInterface $resetPasswordHelper, MessageBusInterface $messageBus, UserPasswordEncoderInterface $userPasswordEncoder)
{
$this->userRepository = $userRepository;
$this->resetPasswordHelper = $resetPasswordHelper;
$this->messageBus = $messageBus;
$this->userPasswordEncoder = $userPasswordEncoder;
}

public function supports($data, array $context = []): bool
{
if (!$data instanceof ResetPasswordInput) {
return false;
}

if (isset($context['collection_operation_name']) && 'post' === $context['collection_operation_name']) {
return true;
}

if (isset($context['item_operation_name']) && 'put' === $context['item_operation_name']) {
return true;
}

return false;
}

/**
* @param ResetPasswordInput $data
*/
public function persist($data, array $context = []): void
{
if (isset($context['collection_operation_name']) && 'post' === $context['collection_operation_name']) {
$this->generateRequest($data->email);

return;
}

if (isset($context['item_operation_name']) && 'put' === $context['item_operation_name']) {
if (!$context['previous_data'] instanceof User) {
return;
}

$this->changePassword($context['previous_data'], $data->plainTextPassword);
}
}

public function remove($data, array $context = []): void
{
throw new \RuntimeException('Operation not supported.');
}

private function generateRequest(string $email): void
{
$user = $this->userRepository->findOneBy(['email' => $email]);

if (!$user instanceof User) {
return;
}

$token = $this->resetPasswordHelper->generateResetToken($user);

/** @psalm-suppress PossiblyNullArgument */
$this->messageBus->dispatch(new SendResetPasswordMessage($user->getEmail(), $token));
}

private function changePassword(User $previousUser, string $plainTextPassword): void
{
$userId = $previousUser->getId();

$user = $this->userRepository->find($userId);

if (null === $user) {
return;
}

$encoded = $this->userPasswordEncoder->encodePassword($user, $plainTextPassword);

$this->userRepository->upgradePassword($user, $encoded);
}
}
```

## Security Issues
For **security related vulnerabilities**, we ask that you send an email to
`ryan [at] symfonycasts.com` instead of creating an issue.
Expand Down