diff --git a/src/Maker/MakeResetPassword.php b/src/Maker/MakeResetPassword.php index 011b422eb..e67382a4c 100644 --- a/src/Maker/MakeResetPassword.php +++ b/src/Maker/MakeResetPassword.php @@ -55,6 +55,7 @@ class MakeResetPassword extends AbstractMaker private $fileManager; private $doctrineHelper; private $entityClassGenerator; + private $generateApi = false; public function __construct(FileManager $fileManager, DoctrineHelper $doctrineHelper, EntityClassGenerator $entityClassGenerator) { @@ -172,6 +173,11 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma [Validator::class, 'notBlank'] ) ); + + $this->generateApi = $io->confirm('Do you want to implement API based Password Resets?', false); + + //@TODO Check if API Platform is installed, if true - continue, if false - alert user composer require api-platform + // @TODO May make more sense to ask this at the top and fail if yes and api platform is not installed. } public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) @@ -182,6 +188,26 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen 'Entity\\' ); + $userDoctrineDetails = $this->doctrineHelper->createDoctrineDetails($userClassNameDetails->getFullName()); + + $userRepoVars = [ + 'repository_full_class_name' => 'Doctrine\ORM\EntityManagerInterface', + 'repository_class_name' => 'EntityManagerInterface', + 'repository_property_var' => 'manager', + 'repository_var' => '$manager', + ]; + + if (null !== $userDoctrineDetails && null !== ($userRepository = $userDoctrineDetails->getRepositoryClass())) { + $userRepoClassDetails = $generator->createClassNameDetails('\\'.$userRepository, 'Repository\\', 'Repository'); + + $userRepoVars = [ + 'repository_full_class_name' => $userRepoClassDetails->getFullName(), + 'repository_class_name' => $userRepoClassDetails->getShortName(), + 'repository_property_var' => lcfirst($userRepoClassDetails->getShortName()), + 'repository_var' => sprintf('$%s', lcfirst($userRepoClassDetails->getShortName())), + ]; + } + $controllerClassNameDetails = $generator->createClassNameDetails( 'ResetPasswordController', 'Controller\\' @@ -266,6 +292,46 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen 'resetPassword/twig_reset.tpl.php' ); + if ($this->generateApi) { + $dtoClassNameDetails = $generator->createClassNameDetails( + 'ResetPasswordInput', + 'Dto\\' + ); + + $generator->generateClass( + $dtoClassNameDetails->getFullName(), + 'resetPassword/ResetPasswordInput.tpl.php' + ); + + $dataTransformerClassNameDetails = $generator->createClassNameDetails( + 'ResetPasswordInputDataTransformer', + 'DataTransformer\\' + ); + + $generator->generateClass( + $dataTransformerClassNameDetails->getFullName(), + 'resetPassword/ResetPasswordInputDataTransformer.tpl.php', + ); + + $dataPersisterClassNameDetails = $generator->createClassNameDetails( + 'ResetPasswordDataPersister', + 'DataPersister\\' + ); + + $generator->generateClass( + $dataPersisterClassNameDetails->getFullName(), + 'resetPassword/ResetPasswordDataPersister.tpl.php', + array_merge([ + 'user_full_class_name' => $userClassNameDetails->getFullName(), + 'user_class_name' => $userClassNameDetails->getShortName(), + ], + $userRepoVars + ) + ); + +// @TODO - Add API DocBlocks to Entity + } + $generator->writeChanges(); $this->writeSuccessMessage($io); diff --git a/src/Resources/skeleton/resetPassword/ResetPasswordDataPersister.tpl.php b/src/Resources/skeleton/resetPassword/ResetPasswordDataPersister.tpl.php new file mode 100644 index 000000000..835574128 --- /dev/null +++ b/src/Resources/skeleton/resetPassword/ResetPasswordDataPersister.tpl.php @@ -0,0 +1,114 @@ + + +namespace ; + +use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface; +use App\Dto\ResetPasswordInput; +use ; +use App\Message\SendResetPasswordMessage; +use ; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; +use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface; + +class implements ContextAwareDataPersisterInterface +{ + private ; + private $resetPasswordHelper; + private $messageBus; + private $userPasswordEncoder; + + public function __construct( , ResetPasswordHelperInterface $resetPasswordHelper, MessageBusInterface $messageBus, UserPasswordEncoderInterface $userPasswordEncoder) + { + $this-> = ; + $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 ) { + 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 + { + + $repository = $this->->getRepository(::class); + $user = $repository->findOneBy(['email' => $data->email]); + + $user = $this->->findOneBy(['email' => $data->email]); + + + if (!$user instanceof ) { + return; + } + + $token = $this->resetPasswordHelper->generateResetToken($user); + + $this->messageBus->dispatch(new SendResetPasswordMessage($user->getEmail(), $token)); + } + + private function changePassword( $previousUser, string $plainTextPassword): void + { + $userId = $previousUser->getId(); + + + $repository = $this->->getRepository(::class); + $user = $repository->find($userId); + + $user = $this->->find($userId); + + + if (null === $user) { + return; + } + + $encoded = $this->userPasswordEncoder->encodePassword($user, $plainTextPassword); + + + $user->setPassword($encoded); + + $repository->flush(); + + $this->->upgradePassword($user, $encoded); + + } +} diff --git a/src/Resources/skeleton/resetPassword/ResetPasswordInput.tpl.php b/src/Resources/skeleton/resetPassword/ResetPasswordInput.tpl.php new file mode 100644 index 000000000..3764af176 --- /dev/null +++ b/src/Resources/skeleton/resetPassword/ResetPasswordInput.tpl.php @@ -0,0 +1,28 @@ + + +namespace ; + +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +class +{ + /** + * @Assert\NotBlank(groups={"postValidation"}) + * @Assert\Email(groups={"postValidation"}) + * @Groups({"reset-password:post"}) + */ + public $email = null; + + /** + * @Assert\NotBlank(groups={"putValidation"}) + * @Groups({"reset-password:put"}) + */ + public $token = null; + + /** + * @Assert\NotBlank(groups={"putValidation"}) + * @Groups({"reset-password:put"}) + */ + public $plainTextPassword = null; +} diff --git a/src/Resources/skeleton/resetPassword/ResetPasswordInputDataTransformer.tpl.php b/src/Resources/skeleton/resetPassword/ResetPasswordInputDataTransformer.tpl.php new file mode 100644 index 000000000..7438fde15 --- /dev/null +++ b/src/Resources/skeleton/resetPassword/ResetPasswordInputDataTransformer.tpl.php @@ -0,0 +1,24 @@ + + +namespace ; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use App\Dto\ResetPasswordInput; +use App\Entity\ResetPasswordRequest; + +class implements DataTransformerInterface +{ + public function transform($object, string $to, array $context = []): object + { + return $object; + } + + public function supportsTransformation($data, string $to, array $context = []): bool + { + if ($data instanceof ResetPasswordRequest) { + return false; + } + + return ResetPasswordRequest::class === $to && ($context['input']['class'] ?? null) === ResetPasswordInput::class; + } +} diff --git a/tests/Maker/MakeResetPasswordTest.php b/tests/Maker/MakeResetPasswordTest.php index 518b2c330..784557d93 100644 --- a/tests/Maker/MakeResetPasswordTest.php +++ b/tests/Maker/MakeResetPasswordTest.php @@ -24,10 +24,11 @@ public function getTestDetails() yield 'reset_password_replaces_flex_config' => [MakerTestDetails::createTest( $this->getMakerInstance(MakeResetPassword::class), [ - 'App\Entity\User', - 'app_home', - 'jr@rushlow.dev', - 'SymfonyCasts', + 'App\Entity\User', // User Entity + 'app_home', // Redirect route after successful reset + 'jr@rushlow.dev', // Email from address + 'SymfonyCasts', // Email from name + 'n', // Generate API templates ]) ->setRequiredPhpVersion(70200) ->addExtraDependencies('security-bundle') @@ -70,10 +71,11 @@ function (string $output, string $directory) { yield 'reset_password_custom_config' => [MakerTestDetails::createTest( $this->getMakerInstance(MakeResetPassword::class), [ - 'App\Entity\User', - 'app_home', - 'jr@rushlow.dev', - 'SymfonyCasts', + 'App\Entity\User', // User Entity + 'app_home', // Redirect route after successful reset + 'jr@rushlow.dev', // Email from address + 'SymfonyCasts', // Email from name + 'n', // Generate API templates ]) ->setRequiredPhpVersion(70200) ->addExtraDependencies('security-bundle') @@ -97,10 +99,11 @@ function (string $output, string $directory) { yield 'reset_password_amends_config' => [MakerTestDetails::createTest( $this->getMakerInstance(MakeResetPassword::class), [ - 'App\Entity\User', - 'app_home', - 'jr@rushlow.dev', - 'SymfonyCasts', + 'App\Entity\User', // User Entity + 'app_home', // Redirect route after successful reset + 'jr@rushlow.dev', // Email from address + 'SymfonyCasts', // Email from name + 'n', // Generate API templates ]) ->setRequiredPhpVersion(70200) ->addExtraDependencies('security-bundle') @@ -128,10 +131,11 @@ function (string $output, string $directory) { yield 'reset_password_functional_test' => [MakerTestDetails::createTest( $this->getMakerInstance(MakeResetPassword::class), [ - 'App\Entity\User', - 'app_home', - 'jr@rushlow.dev', - 'SymfonyCasts', + 'App\Entity\User', // User Entity + 'app_home', // Redirect route after successful reset + 'jr@rushlow.dev', // Email from address + 'SymfonyCasts', // Email from name + 'n', // Generate API templates ]) ->setRequiredPhpVersion(70200) ->addExtraDependencies('doctrine') @@ -147,12 +151,13 @@ function (string $output, string $directory) { yield 'reset_password_custom_user' => [MakerTestDetails::createTest( $this->getMakerInstance(MakeResetPassword::class), [ - 'App\Entity\UserCustom', - 'emailAddress', - 'setMyPassword', - 'app_home', - 'jr@rushlow.dev', - 'SymfonyCasts', + 'App\Entity\UserCustom', // User Entity + 'emailAddress', // Email property + 'setMyPassword', // Email setter + 'app_home', // Redirect route after successful reset + 'jr@rushlow.dev', // Email from address + 'SymfonyCasts', // Email from name + 'n', // Generate API Templates ]) ->setRequiredPhpVersion(70200) ->addExtraDependencies('security-bundle') @@ -180,5 +185,71 @@ function (string $output, string $directory) { } ), ]; + + yield 'reset_password_api' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeResetPassword::class), + [ + 'App\Entity\User', // User Entity + 'app_home', // Redirect route after successful reset + 'jr@rushlow.dev', // Email from address + 'SymfonyCasts', // Email from name + 'y', // Generate API templates + ]) + ->setRequiredPhpVersion(70200) + ->addExtraDependencies('api') + ->addExtraDependencies('security-bundle') + ->addExtraDependencies('twig') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeResetPasswordApi') + ->assert( + function (string $output, string $directory) { + + $this->assertStringContainsString('Success', $output); + + $fs = new Filesystem(); + + $generatedFiles = [ + 'src/DataPersister/ResetPasswordDataPersister.php', + 'src/DataTransformer/ResetPasswordInputDataTransformer.php', + 'src/Dto/ResetPasswordInput.php', + ]; + + foreach ($generatedFiles as $file) { + $this->assertTrue($fs->exists(sprintf('%s/%s', $directory, $file))); + } + + // check ResetPasswordDto + $contentResetPasswordInput = file_get_contents($directory.'/src/Dto/ResetPasswordInput.php'); + $this->assertStringContainsString('@Groups({"reset-password:write"})', $contentResetPasswordInput); + $this->assertStringContainsString('public ?string $email = null;', $contentResetPasswordInput); + + // check ResetPasswordInputDataTransformer + $contentResetPasswordDataTransformer = file_get_contents($directory.'/src/DataTransformer/ResetPasswordInputDataTransformer.php'); + + // check ResetPasswordDataPersister + $contentResetPasswordRequestFormType = file_get_contents($directory.'/src/DataPersister/ResetPasswordDataPersister.php'); + } + ), + ]; + + yield 'reset_password_api_functional_test' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeResetPassword::class), + [ + 'App\Entity\User', // User Entity + 'app_home', // Redirect route after successful reset + 'jr@rushlow.dev', // Email from address + 'SymfonyCasts', // Email from name + 'y', // Generate API templates + ]) + ->setRequiredPhpVersion(70200) + ->addExtraDependencies('api-platform') + ->addExtraDependencies('doctrine') + ->addExtraDependencies('doctrine/annotations') + ->addExtraDependencies('mailer') + ->addExtraDependencies('security-bundle') + ->addExtraDependencies('symfony/form') + ->addExtraDependencies('symfony/validator') + ->addExtraDependencies('twig') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeResetPasswordApiFunctionalTest'), + ]; } } diff --git a/tests/fixtures/MakeResetPasswordApi/config/packages/security.yml b/tests/fixtures/MakeResetPasswordApi/config/packages/security.yml new file mode 100644 index 000000000..0cdc923bd --- /dev/null +++ b/tests/fixtures/MakeResetPasswordApi/config/packages/security.yml @@ -0,0 +1,3 @@ +security: + encoders: + App\Entity\User: bcrypt diff --git a/tests/fixtures/MakeResetPasswordApi/src/Entity/User.php b/tests/fixtures/MakeResetPasswordApi/src/Entity/User.php new file mode 100644 index 000000000..98fcf97e9 --- /dev/null +++ b/tests/fixtures/MakeResetPasswordApi/src/Entity/User.php @@ -0,0 +1,38 @@ +