-
Notifications
You must be signed in to change notification settings - Fork 66
[API] add docs to show API Platform implementation. #147
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
base: main
Are you sure you want to change the base?
Changes from 4 commits
e63314a
b713e09
9cdbce5
eaf9a7f
bd2f8cc
d316a1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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')", | ||
* 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' => 'someone@example.com']`. | ||
|
||
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 <jr@rushlow.dev> | ||
*/ | ||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Another option would be to create these 2 DTO's and make THEM each their own |
||
} | ||
``` | ||
|
||
```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 <jr@rushlow.dev> | ||
*/ | ||
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. | ||
|
Uh oh!
There was an error while loading. Please reload this page.