refactor #14139 [Admin][API] Password reset (NoResponseMate)

This PR was merged into the 1.12 branch.

Discussion
----------

| Q               | A                                                            |
|-----------------|--------------------------------------------------------------|
| Branch?         | master          |
| Bug fix?        | no                                                       |
| New feature?    | yes                                                       |
| BC breaks?      | no                                                       |
| Deprecations?   | no |
| Related tickets | -                      |
| License         | MIT                                                          |

<br>

- Split the `ResetPassword` api resource in two, one focusing customer's password (`Sylius\Bundle\ApiBundle\Command\Account\ResetPassword`) and the other administrator's (`Sylius\Bundle\ApiBundle\Command\Admin\ResetPassword`)
- Add API endpoint for resetting administrator's password at `<api>/admin/reset-password-requests/{resetPasswordToken}`
- Extract password reset logic to a standalone service `Sylius\Bundle\CoreBundle\Security\UserPasswordResetter`

Commits
-------

b2c36834fd Add administrator password reset api tests
27248acd57 Change user transformer in admin context
3f2e7c787d Add basic handling of admin's password reset
4246bda8c6 Add admin's side password reset
01714dbe37 [API] Split ResetPassword resource
8b327372c7 [Core][API] Extract Password reset handling to a standalone service
707161a1ac [API] Update ResetPasswordHandler upgrade notice
3ff8920737 Fix coding style and minor issues
This commit is contained in:
Grzegorz Sadowski 2022-07-13 07:36:44 +02:00 committed by GitHub
commit 9fb6eb3ca3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 536 additions and 147 deletions

View file

@ -70,7 +70,9 @@ Here is how the response looks like:
Now when we do not provide parameters in response it returns all available `paymentMethods` in channel.
Wrong parameters otherwise cause empty array `[]` in response and correct parameters return `paymentMethods` available for your `payment`.
1. The 2nd parameter `MetadataInterface` has been removed from `src/Sylius/Bundle/ApiBundle/CommandHandler/Account/ResetPasswordHandler` and replaced by `Sylius\Component\User\Security\PasswordUpdaterInterface` (previously 3rd parameter). From now on a token TTL value must be used instead as the 3rd parameter.
1. All arguments of `src/Sylius/Bundle/ApiBundle/CommandHandler/Account/ResetPasswordHandler` have been removed and substituted with `Sylius\Bundle\CoreBundle\Security\UserPasswordResetter`.
1. The file `src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/ResetPassword.xml` has been renamed to `src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/AccountResetPassword.xml` and its short name has been changed from `ResetPasswordRequest` to `AccountResetPasswordRequest`.
1. Constructor of `Sylius\Bundle\ApiBundle\CommandHandler\Account\RequestResetPasswordTokenHandler` has been extended with `Sylius\Calendar\Provider\DateTimeProviderInterface` argument:

View file

@ -15,18 +15,21 @@ Feature: Resetting an administrator's password
Then I should be notified that email with reset instruction has been sent
And an email with instructions on how to reset the administrator's password should be sent to "sylius@example.com"
@todo
@api
Scenario: Changing my administrator's password
Given I have already received an administrator's password resetting email
When I reset my password using the received instructions
Given I have already received a resetting password email
When I follow the instructions to reset my password
And I specify my new password as "newp@ssw0rd"
And I confirm my new password as "newp@ssw0rd"
And I reset it
Then I should be notified that my password has been successfully changed
And I should be able to log in as "sylius@example.com" with "newp@ssw0rd" password
And I should be able to log in as "sylius@example.com" authenticated by "newp@ssw0rd" password
@todo
@api
Scenario: Trying to change my administrator's password twice without sending a new password reset request
Given I already reset my administrator's password
When I try to reset my password again using the same email
Then I should not be able to change it again without sending a new password reset request
Given I have already received an administrator's password resetting email
When I follow the instructions to reset my password
And I specify my new password as "newp@ssw0rd"
And I confirm my new password as "newp@ssw0rd"
And I reset it
Then I should not be able to change my password again with the same token

View file

@ -18,6 +18,9 @@ use Sylius\Behat\Client\ApiClientInterface;
use Sylius\Behat\Client\ApiSecurityClientInterface;
use Sylius\Behat\Client\RequestFactoryInterface;
use Sylius\Behat\Client\RequestInterface;
use Sylius\Behat\Client\ResponseCheckerInterface;
use Sylius\Component\Core\Model\AdminUserInterface;
use Symfony\Component\HttpFoundation\Request as HTTPRequest;
use Symfony\Component\HttpFoundation\Response;
use Webmozart\Assert\Assert;
@ -29,6 +32,8 @@ final class LoginContext implements Context
private ApiSecurityClientInterface $apiSecurityClient,
private ApiClientInterface $client,
private RequestFactoryInterface $requestFactory,
private ResponseCheckerInterface $responseChecker,
private string $apiUrlPrefix
) {
}
@ -89,6 +94,33 @@ final class LoginContext implements Context
$this->client->executeCustomRequest($this->request);
}
/**
* @When /^(I) follow the instructions to reset my password$/
*/
public function iFollowTheInstructionsToResetMyPassword(AdminUserInterface $admin): void
{
$this->request = $this->requestFactory->custom(
sprintf('%s/admin/reset-password-requests/%s', $this->apiUrlPrefix, $admin->getPasswordResetToken()),
HttpRequest::METHOD_PATCH,
);
}
/**
* @When I specify my new password as :password
*/
public function iSpecifyMyNewPassword(string $password): void
{
$this->request->updateContent(['newPassword' => $password]);
}
/**
* @When I confirm my new password as :password
*/
public function iConfirmMyNewPassword(string $password): void
{
$this->request->updateContent(['confirmNewPassword' => $password]);
}
/**
* @Then I should be logged in
*/
@ -139,6 +171,28 @@ final class LoginContext implements Context
Assert::same($this->client->getLastResponse()->getStatusCode(), Response::HTTP_ACCEPTED);
}
/**
* @Then I should be notified that my password has been successfully changed
*/
public function iShouldBeNotifiedThatMyPasswordHasBeenSuccessfullyChanged(): void
{
Assert::same($this->client->getLastResponse()->getStatusCode(), Response::HTTP_ACCEPTED);
}
/**
* @Then I should not be able to change my password again with the same token
*/
public function iShouldNotBeAbleToChangeMyPasswordAgainWithTheSameToken(): void
{
$this->client->executeCustomRequest($this->request);
$lastResponse = $this->client->getLastResponse();
Assert::same($lastResponse->getStatusCode(), Response::HTTP_INTERNAL_SERVER_ERROR);
$message = $this->responseChecker->getError($lastResponse);
Assert::startsWith($message, 'No user found with reset token: ');
}
private function logIn(string $username, string $password): void
{
$this->iWantToLogIn();

View file

@ -98,4 +98,15 @@ final class AdminUserContext implements Context
$this->sharedStorage->set($avatarPath, $avatar->getPath());
}
/**
* @Given /^(I) have already received an administrator's password resetting email$/
*/
public function iHaveAlreadyReceivedAnAdministratorsPasswordResettingEmail(AdminUserInterface $administrator): void
{
$administrator->setPasswordResetToken('token');
$administrator->setPasswordRequestedAt(new \DateTime());
$this->objectManager->flush();
}
}

View file

@ -28,6 +28,7 @@
<argument type="service" id="sylius.behat.api_platform_client.shop" />
<argument type="service" id="sylius.behat.request_factory" />
<argument type="service" id="Sylius\Behat\Client\ResponseCheckerInterface" />
<argument>%sylius.security.new_api_route%</argument>
</service>
<service id="sylius.behat.context.api.admin.managing_administrators" class="Sylius\Behat\Context\Api\Admin\ManagingAdministratorsContext">

View file

@ -7,7 +7,7 @@ default:
contexts:
- sylius.behat.context.hook.doctrine_orm
- sylius.behat.context.transform.user
- sylius.behat.context.transform.admin
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.admin_security

View file

@ -16,17 +16,10 @@ namespace Sylius\Bundle\ApiBundle\Command\Account;
/** @experimental */
class ResetPassword
{
/** @var string|null */
public $newPassword;
/** @var string|null */
public $confirmNewPassword;
/** @var string */
public $resetPasswordToken;
public function __construct(string $resetPasswordToken)
{
$this->resetPasswordToken = $resetPasswordToken;
public function __construct(
public string $resetPasswordToken,
public ?string $newPassword = null,
public ?string $confirmNewPassword = null,
) {
}
}

View file

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Command\Admin;
/** @experimental */
class ResetPassword
{
public function __construct(
public string $resetPasswordToken,
public ?string $newPassword = null,
public ?string $confirmNewPassword = null,
) {
}
}

View file

@ -14,38 +14,18 @@ declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\CommandHandler\Account;
use Sylius\Bundle\ApiBundle\Command\Account\ResetPassword;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\User\Repository\UserRepositoryInterface;
use Sylius\Component\User\Security\PasswordUpdaterInterface;
use Sylius\Bundle\CoreBundle\Security\UserPasswordResetterInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Webmozart\Assert\Assert;
/** @experimental */
final class ResetPasswordHandler implements MessageHandlerInterface
{
public function __construct(
private UserRepositoryInterface $userRepository,
private PasswordUpdaterInterface $passwordUpdater,
private string $tokenTtl
) {
public function __construct(private UserPasswordResetterInterface $userPasswordResetter)
{
}
public function __invoke(ResetPassword $command): void
{
/** @var ShopUserInterface|null $user */
$user = $this->userRepository->findOneBy(['passwordResetToken' => $command->resetPasswordToken]);
Assert::notNull($user, 'No user found with reset token: ' . $command->resetPasswordToken);
$lifetime = new \DateInterval($this->tokenTtl);
if (!$user->isPasswordRequestNonExpired($lifetime)) {
throw new \InvalidArgumentException('Password reset token has expired');
}
$user->setPlainPassword($command->newPassword);
$this->passwordUpdater->updatePassword($user);
$user->setPasswordResetToken(null);
$this->userPasswordResetter->reset($command->resetPasswordToken, $command->newPassword);
}
}

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\CommandHandler\Admin;
use Sylius\Bundle\ApiBundle\Command\Admin\ResetPassword;
use Sylius\Bundle\CoreBundle\Security\UserPasswordResetterInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/** @experimental */
final class ResetPasswordHandler implements MessageHandlerInterface
{
public function __construct(private UserPasswordResetterInterface $userPasswordResetter)
{
}
public function __invoke(ResetPassword $command): void
{
$this->userPasswordResetter->reset($command->resetPasswordToken, $command->newPassword);
}
}

View file

@ -18,7 +18,7 @@ use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use Sylius\Bundle\ApiBundle\Command\Account\ResetPassword;
/** @experimental */
final class ResetPasswordItemDataProvider implements RestrictedDataProviderInterface, ItemDataProviderInterface
final class AccountResetPasswordItemDataProvider implements RestrictedDataProviderInterface, ItemDataProviderInterface
{
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{

View file

@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\DataProvider;
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use Sylius\Bundle\ApiBundle\Command\Admin\ResetPassword;
/** @experimental */
final class AdminResetPasswordItemDataProvider implements RestrictedDataProviderInterface, ItemDataProviderInterface
{
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
return new ResetPassword($id);
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return is_a($resourceClass, ResetPassword::class, true);
}
}

View file

@ -15,7 +15,7 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://api-platform.com/schema/metadata https://api-platform.com/schema/metadata/metadata-2.0.xsd"
>
<resource class="Sylius\Bundle\ApiBundle\Command\Account\ResetPassword" shortName="ResetPasswordRequest">
<resource class="Sylius\Bundle\ApiBundle\Command\Account\ResetPassword" shortName="AccountResetPasswordRequest">
<attribute name="validation_groups">sylius</attribute>
<attribute name="messenger">input</attribute>
@ -33,20 +33,6 @@
<attribute name="summary">Requests password reset</attribute>
</attribute>
</collectionOperation>
<collectionOperation name="admin_create_reset_password_request">
<attribute name="method">POST</attribute>
<attribute name="path">/admin/reset-password-requests</attribute>
<attribute name="input">Sylius\Bundle\CoreBundle\Message\Admin\Account\RequestResetPasswordEmail</attribute>
<attribute name="output">false</attribute>
<attribute name="status">202</attribute>
<attribute name="denormalization_context">
<attribute name="groups">admin:reset_password:create</attribute>
</attribute>
<attribute name="openapi_context">
<attribute name="summary">Requests administrator's password reset</attribute>
</attribute>
</collectionOperation>
</collectionOperations>
<itemOperations>

View file

@ -0,0 +1,58 @@
<?xml version="1.0" ?>
<!--
This file is part of the Sylius package.
(c) Paweł Jędrzejewski
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
-->
<resources xmlns="https://api-platform.com/schema/metadata"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://api-platform.com/schema/metadata https://api-platform.com/schema/metadata/metadata-2.0.xsd"
>
<resource class="Sylius\Bundle\ApiBundle\Command\Admin\ResetPassword" shortName="AdminResetPasswordRequest">
<attribute name="validation_groups">sylius</attribute>
<attribute name="messenger">input</attribute>
<collectionOperations>
<collectionOperation name="admin_create_reset_password_request">
<attribute name="method">POST</attribute>
<attribute name="path">/admin/reset-password-requests</attribute>
<attribute name="input">Sylius\Bundle\CoreBundle\Message\Admin\Account\RequestResetPasswordEmail</attribute>
<attribute name="output">false</attribute>
<attribute name="status">202</attribute>
<attribute name="denormalization_context">
<attribute name="groups">admin:reset_password:create</attribute>
</attribute>
<attribute name="openapi_context">
<attribute name="summary">Requests administrator's password reset</attribute>
</attribute>
</collectionOperation>
</collectionOperations>
<itemOperations>
<itemOperation name="admin_update_reset_password_request">
<attribute name="method">PATCH</attribute>
<attribute name="path">/admin/reset-password-requests/{resetPasswordToken}</attribute>
<attribute name="input">Sylius\Bundle\ApiBundle\Command\Admin\ResetPassword</attribute>
<attribute name="output">false</attribute>
<attribute name="status">202</attribute>
<attribute name="denormalization_context">
<attribute name="groups">admin:reset_password:update</attribute>
</attribute>
<attribute name="openapi_context">
<attribute name="summary">Resets administrator's password</attribute>
</attribute>
</itemOperation>
</itemOperations>
<property name="resetPasswordToken" identifier="true" />
<property name="newPassword" required="true" />
<property name="confirmNewPassword" required="true" />
</resource>
</resources>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" ?>
<!--
This file is part of the Sylius package.
(c) Paweł Jędrzejewski
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
-->
<serializer xmlns="http://symfony.com/schema/dic/serializer-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/serializer-mapping https://symfony.com/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd"
>
<class name="Sylius\Bundle\ApiBundle\Command\Admin\ResetPassword">
<attribute name="newPassword">
<group>admin:reset_password:update</group>
</attribute>
<attribute name="confirmNewPassword">
<group>admin:reset_password:update</group>
</attribute>
</class>
</serializer>

View file

@ -167,9 +167,7 @@
</service>
<service id="Sylius\Bundle\ApiBundle\CommandHandler\Account\ResetPasswordHandler">
<argument type="service" id="sylius.repository.shop_user" />
<argument type="service" id="sylius.security.password_updater" />
<argument type="string">%sylius.shop_user.token.password_reset.ttl%</argument>
<argument type="service" id="sylius.user_password_resetter.shop" />
<tag name="messenger.message_handler" bus="sylius.command_bus" />
<tag name="messenger.message_handler" bus="sylius_default.bus" />
</service>
@ -190,6 +188,12 @@
<tag name="messenger.message_handler" bus="sylius_default.bus" />
</service>
<service id="Sylius\Bundle\ApiBundle\CommandHandler\Admin\ResetPasswordHandler">
<argument type="service" id="sylius.user_password_resetter.admin" />
<tag name="messenger.message_handler" bus="sylius.command_bus" />
<tag name="messenger.message_handler" bus="sylius_default.bus" />
</service>
<service id="Sylius\Bundle\ApiBundle\CommandHandler\Checkout\SendOrderConfirmationHandler">
<argument type="service" id="sylius.email_sender" />
<argument type="service" id="sylius.repository.order" />

View file

@ -18,6 +18,14 @@
<services>
<defaults public="true" />
<service id="Sylius\Bundle\ApiBundle\DataProvider\AccountResetPasswordItemDataProvider">
<tag name="api_platform.item_data_provider" priority="10" />
</service>
<service id="Sylius\Bundle\ApiBundle\DataProvider\AdminResetPasswordItemDataProvider">
<tag name="api_platform.item_data_provider" priority="10" />
</service>
<service id="Sylius\Bundle\ApiBundle\DataProvider\OrderItemItemDataProvider">
<argument type="service" id="sylius.repository.order_item" />
<argument type="service" id="Sylius\Bundle\ApiBundle\Context\UserContextInterface" />
@ -63,10 +71,6 @@
<tag name="api_platform.item_data_provider" priority="10" />
</service>
<service id="Sylius\Bundle\ApiBundle\DataProvider\ResetPasswordItemDataProvider">
<tag name="api_platform.item_data_provider" priority="10" />
</service>
<service id="Sylius\Bundle\ApiBundle\DataProvider\ShippingMethodsCollectionDataProvider">
<argument type="service" id="sylius.repository.shipment" />
<argument type="service" id="sylius.repository.shipping_method" />

View file

@ -14,20 +14,15 @@ declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\CommandHandler\Account;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\ApiBundle\Command\Account\ResetPassword;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\User\Repository\UserRepositoryInterface;
use Sylius\Component\User\Security\PasswordUpdaterInterface;
use Sylius\Bundle\CoreBundle\Security\UserPasswordResetterInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final class ResetPasswordHandlerSpec extends ObjectBehavior
{
function let(
UserRepositoryInterface $userRepository,
PasswordUpdaterInterface $passwordUpdater
): void {
$this->beConstructedWith($userRepository, $passwordUpdater, 'P5D');
function let(UserPasswordResetterInterface $userPasswordResetter): void
{
$this->beConstructedWith($userPasswordResetter);
}
function it_is_a_message_handler(): void
@ -35,77 +30,13 @@ final class ResetPasswordHandlerSpec extends ObjectBehavior
$this->shouldImplement(MessageHandlerInterface::class);
}
function it_resets_password(
UserRepositoryInterface $userRepository,
ShopUserInterface $shopUser,
PasswordUpdaterInterface $passwordUpdater
): void {
$userRepository->findOneBy(['passwordResetToken' => 'TOKEN'])->willReturn($shopUser);
$shopUser->isPasswordRequestNonExpired(Argument::that(function (\DateInterval $dateInterval) {
return $dateInterval->format('%d') === '5';
}))->willReturn(true);
$shopUser->getPasswordResetToken()->willReturn('TOKEN');
$shopUser->setPlainPassword('newPassword')->shouldBeCalled();
$passwordUpdater->updatePassword($shopUser)->shouldBeCalled();
$shopUser->setPasswordResetToken(null)->shouldBeCalled();
function it_delegates_password_resetting(UserPasswordResetterInterface $userPasswordResetter): void
{
$command = new ResetPassword('TOKEN');
$command->newPassword = 'newPassword';
$command->resetPasswordToken = 'TOKEN';
$userPasswordResetter->reset('TOKEN', 'newPassword')->shouldBeCalled();
$this->__invoke($command);
}
function it_throws_exception_if_token_is_expired(
UserRepositoryInterface $userRepository,
ShopUserInterface $shopUser,
PasswordUpdaterInterface $passwordUpdater
): void {
$userRepository->findOneBy(['passwordResetToken' => 'TOKEN'])->willReturn($shopUser);
$shopUser->isPasswordRequestNonExpired(Argument::that(function (\DateInterval $dateInterval) {
return $dateInterval->format('%d') === '5';
}))->willReturn(false);
$shopUser->getPasswordResetToken()->willReturn('TOKEN');
$shopUser->setPlainPassword('newPassword')->shouldNotBeCalled();
$passwordUpdater->updatePassword($shopUser)->shouldNotBeCalled();
$command = new ResetPassword('TOKEN');
$command->newPassword = 'newPassword';
$command->resetPasswordToken = 'TOKEN';
$this
->shouldThrow(\InvalidArgumentException::class)
->during('__invoke', [$command])
;
}
function it_throws_exception_if_tokens_are_not_exact(
UserRepositoryInterface $userRepository,
ShopUserInterface $shopUser,
PasswordUpdaterInterface $passwordUpdater
): void {
$userRepository->findOneBy(['passwordResetToken' => 'TOKEN'])->willReturn($shopUser);
$shopUser->isPasswordRequestNonExpired(Argument::that(function (\DateInterval $dateInterval) {
return $dateInterval->format('%d') === '5';
}))->willReturn(false);
$shopUser->getPasswordResetToken()->willReturn('BADTOKEN');
$shopUser->setPlainPassword('newPassword')->shouldNotBeCalled();
$passwordUpdater->updatePassword($shopUser)->shouldNotBeCalled();
$command = new ResetPassword('TOKEN');
$command->newPassword = 'newPassword';
$command->resetPasswordToken = 'TOKEN';
$this
->shouldThrow(\InvalidArgumentException::class)
->during('__invoke', [$command])
;
}
}

View file

@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\CommandHandler\Admin;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Command\Admin\ResetPassword;
use Sylius\Bundle\CoreBundle\Security\UserPasswordResetterInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final class ResetPasswordHandlerSpec extends ObjectBehavior
{
function let(UserPasswordResetterInterface $userPasswordResetter): void
{
$this->beConstructedWith($userPasswordResetter);
}
function it_is_a_message_handler(): void
{
$this->shouldImplement(MessageHandlerInterface::class);
}
function it_delegates_password_resetting(UserPasswordResetterInterface $userPasswordResetter): void
{
$command = new ResetPassword('TOKEN');
$command->newPassword = 'newPassword';
$userPasswordResetter->reset('TOKEN', 'newPassword')->shouldBeCalled();
$this->__invoke($command);
}
}

View file

@ -17,7 +17,7 @@ use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Command\Account\ResetPassword;
use Sylius\Component\Core\Model\AddressInterface;
final class ResetPasswordItemDataProviderSpec extends ObjectBehavior
final class AccountResetPasswordItemDataProviderSpec extends ObjectBehavior
{
function it_supports_only_reset_password(): void
{

View file

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\DataProvider;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Command\Admin\ResetPassword;
use Sylius\Component\Core\Model\AddressInterface;
final class AdminResetPasswordItemDataProviderSpec extends ObjectBehavior
{
function it_supports_only_reset_password(): void
{
$this->supports(ResetPassword::class, 'post')->shouldReturn(true);
$this->supports(AddressInterface::class, 'post')->shouldReturn(false);
}
function it_provides_reset_password_class(): void
{
$this
->getItem(ResetPassword::class, 'resetToken')
->shouldBeLike(new ResetPassword('resetToken'))
;
}
}

View file

@ -268,5 +268,17 @@
<service id="Sylius\Bundle\CoreBundle\Order\Checker\OrderPromotionsIntegrityCheckerInterface" class="Sylius\Bundle\CoreBundle\Order\Checker\OrderPromotionsIntegrityChecker">
<argument type="service" id="sylius.order_processing.order_processor" />
</service>
<service id="sylius.user_password_resetter.admin" class="Sylius\Bundle\CoreBundle\Security\UserPasswordResetter">
<argument type="service" id="sylius.repository.admin_user" />
<argument type="service" id="Sylius\Component\User\Security\PasswordUpdaterInterface" />
<argument type="string">%sylius.admin_user.token.password_reset.ttl%</argument>
</service>
<service id="sylius.user_password_resetter.shop" class="Sylius\Bundle\CoreBundle\Security\UserPasswordResetter">
<argument type="service" id="sylius.repository.shop_user" />
<argument type="service" id="Sylius\Component\User\Security\PasswordUpdaterInterface" />
<argument type="string">%sylius.shop_user.token.password_reset.ttl%</argument>
</service>
</services>
</container>

View file

@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Security;
use Sylius\Component\User\Model\UserInterface;
use Sylius\Component\User\Repository\UserRepositoryInterface;
use Sylius\Component\User\Security\PasswordUpdaterInterface;
use Webmozart\Assert\Assert;
final class UserPasswordResetter implements UserPasswordResetterInterface
{
public function __construct(
private UserRepositoryInterface $userRepository,
private PasswordUpdaterInterface $passwordUpdater,
private string $tokenTtl,
) {
}
public function reset(string $token, string $password): void
{
/** @var UserInterface|null $user */
$user = $this->userRepository->findOneBy(['passwordResetToken' => $token]);
Assert::notNull($user, 'No user found with reset token: ' . $token);
$lifetime = new \DateInterval($this->tokenTtl);
if (!$user->isPasswordRequestNonExpired($lifetime)) {
throw new \InvalidArgumentException('Password reset token has expired');
}
$user->setPlainPassword($password);
$this->passwordUpdater->updatePassword($user);
$user->setPasswordResetToken(null);
$user->setPasswordRequestedAt(null);
}
}

View file

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Security;
interface UserPasswordResetterInterface
{
public function reset(string $token, string $password): void;
}

View file

@ -0,0 +1,91 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\CoreBundle\Security;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\CoreBundle\Security\UserPasswordResetterInterface;
use Sylius\Component\User\Model\UserInterface;
use Sylius\Component\User\Repository\UserRepositoryInterface;
use Sylius\Component\User\Security\PasswordUpdaterInterface;
final class UserPasswordResetterSpec extends ObjectBehavior
{
function let(UserRepositoryInterface $userRepository, PasswordUpdaterInterface $passwordUpdater)
{
$this->beConstructedWith($userRepository, $passwordUpdater, 'P5D');
}
function it_implements_user_password_resetter_interface(): void
{
$this->shouldImplement(UserPasswordResetterInterface::class);
}
function it_resets_password(
UserRepositoryInterface $userRepository,
UserInterface $user,
PasswordUpdaterInterface $passwordUpdater,
): void {
$userRepository->findOneBy(['passwordResetToken' => 'TOKEN'])->willReturn($user);
$user->isPasswordRequestNonExpired(
Argument::that(static fn (\DateInterval $dateInterval) => $dateInterval->format('%d') === '5'),
)->willReturn(true);
$user->getPasswordResetToken()->willReturn('TOKEN');
$user->setPlainPassword('newPassword')->shouldBeCalled();
$passwordUpdater->updatePassword($user)->shouldBeCalled();
$user->setPasswordResetToken(null)->shouldBeCalled();
$user->setPasswordRequestedAt(null)->shouldBeCalled();
$this->reset('TOKEN', 'newPassword');
}
function it_throws_exception_if_no_user_has_been_found_for_token(
UserRepositoryInterface $userRepository,
PasswordUpdaterInterface $passwordUpdater,
): void {
$userRepository->findOneBy(['passwordResetToken' => 'TOKEN'])->willReturn(null);
$passwordUpdater->updatePassword(Argument::any())->shouldNotBeCalled();
$this
->shouldThrow(\InvalidArgumentException::class)
->during('reset', ['TOKEN', 'newPassword'])
;
}
function it_throws_exception_if_token_is_expired(
UserRepositoryInterface $userRepository,
UserInterface $user,
PasswordUpdaterInterface $passwordUpdater,
): void {
$userRepository->findOneBy(['passwordResetToken' => 'TOKEN'])->willReturn($user);
$user->isPasswordRequestNonExpired(
Argument::that(static fn (\DateInterval $dateInterval) => $dateInterval->format('%d') === '5'),
)->willReturn(false);
$user->getPasswordResetToken()->willReturn('TOKEN');
$user->setPlainPassword('newPassword')->shouldNotBeCalled();
$passwordUpdater->updatePassword($user)->shouldNotBeCalled();
$user->setPasswordRequestedAt(null)->shouldNotBeCalled();
$this
->shouldThrow(\InvalidArgumentException::class)
->during('reset', ['TOKEN', 'newPassword'])
;
}
}