From 542591cd5401a8b4c66de919c9c2b3c0e64a048d Mon Sep 17 00:00:00 2001 From: Romaric Drigon Date: Thu, 31 Jan 2019 15:38:49 +0100 Subject: [PATCH] Added a make:forgotten-password maker --- src/Maker/MakeForgottenPassword.php | 333 ++++++++++++++++++ src/Renderer/FormTypeRenderer.php | 4 +- src/Resources/config/makers.xml | 7 + src/Resources/help/MakeForgottenPassword.txt | 5 + .../ForgottenPasswordController.tpl.php | 149 ++++++++ .../PasswordResetToken.tpl.php | 87 +++++ .../PasswordResetTokenRepository.tpl.php | 40 +++ .../twig_check_email.tpl.php | 6 + .../forgottenPassword/twig_email.tpl.php | 7 + .../forgottenPassword/twig_request.tpl.php | 11 + .../forgottenPassword/twig_reset.tpl.php | 11 + src/Security/InteractiveSecurityHelper.php | 56 +++ tests/Maker/FunctionalTest.php | 14 + .../InteractiveSecurityHelperTest.php | 130 +++++++ .../config/packages/security.yml | 3 + .../config/packages/swiftmailer.yaml | 4 + .../MakeForgottenPassword/config/routes.yaml | 2 + .../MakeForgottenPassword/src/Entity/User.php | 104 ++++++ .../tests/ForgottenPasswordControllerTest.php | 307 ++++++++++++++++ 19 files changed, 1278 insertions(+), 2 deletions(-) create mode 100644 src/Maker/MakeForgottenPassword.php create mode 100644 src/Resources/help/MakeForgottenPassword.txt create mode 100644 src/Resources/skeleton/forgottenPassword/ForgottenPasswordController.tpl.php create mode 100644 src/Resources/skeleton/forgottenPassword/PasswordResetToken.tpl.php create mode 100644 src/Resources/skeleton/forgottenPassword/PasswordResetTokenRepository.tpl.php create mode 100644 src/Resources/skeleton/forgottenPassword/twig_check_email.tpl.php create mode 100644 src/Resources/skeleton/forgottenPassword/twig_email.tpl.php create mode 100644 src/Resources/skeleton/forgottenPassword/twig_request.tpl.php create mode 100644 src/Resources/skeleton/forgottenPassword/twig_reset.tpl.php create mode 100644 tests/fixtures/MakeForgottenPassword/config/packages/security.yml create mode 100644 tests/fixtures/MakeForgottenPassword/config/packages/swiftmailer.yaml create mode 100644 tests/fixtures/MakeForgottenPassword/config/routes.yaml create mode 100644 tests/fixtures/MakeForgottenPassword/src/Entity/User.php create mode 100644 tests/fixtures/MakeForgottenPassword/tests/ForgottenPasswordControllerTest.php diff --git a/src/Maker/MakeForgottenPassword.php b/src/Maker/MakeForgottenPassword.php new file mode 100644 index 000000000..143014118 --- /dev/null +++ b/src/Maker/MakeForgottenPassword.php @@ -0,0 +1,333 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker; + +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Doctrine\ORMDependencyBuilder; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper; +use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator; +use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\RepeatedType; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Bundle\MakerBundle\FileManager; +use Symfony\Bundle\MakerBundle\Renderer\FormTypeRenderer; +use Symfony\Component\Validator\Validation; +use Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle; + +/** + * @author Romaric Drigon + * + * @internal + */ +final class MakeForgottenPassword extends AbstractMaker +{ + private $fileManager; + + private $formTypeRenderer; + + private $router; + + public function __construct(FileManager $fileManager, FormTypeRenderer $formTypeRenderer, RouterInterface $router) + { + $this->fileManager = $fileManager; + $this->formTypeRenderer = $formTypeRenderer; + $this->router = $router; + } + + public static function getCommandName(): string + { + return 'make:forgotten-password'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig) + { + $command + ->setDescription('Creates a "forgotten password" mechanism') + ->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeForgottenPassword.txt')) + ; + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command) + { + // initialize arguments & commands that are internal (i.e. meant only to be asked) + $command + ->addArgument('user-class') + ->addArgument('email-field') + ->addArgument('email-getter') + ->addArgument('password-setter') + ; + + $interactiveSecurityHelper = new InteractiveSecurityHelper(); + + if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) { + throw new RuntimeCommandException('The file "config/packages/security.yaml" does not exist. This command needs that file to accurately build the forgotten password form.'); + } + + $manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path)); + $securityData = $manipulator->getData(); + $providersData = $securityData['security']['providers'] ?? []; + + $input->setArgument( + 'user-class', + $userClass = $interactiveSecurityHelper->guessUserClass( + $io, + $providersData, + 'Enter the User class that should be used with the "forgotten password" feature (e.g. App\\Entity\\User)' + ) + ); + $io->text(sprintf('Implementing forgotten password for %s', $userClass)); + + $input->setArgument( + 'email-field', + $interactiveSecurityHelper->guessEmailField($io, $userClass) + ); + $input->setArgument( + 'email-getter', + $interactiveSecurityHelper->guessEmailGetter($io, $userClass) + ); + $input->setArgument( + 'password-setter', + $interactiveSecurityHelper->guessPasswordSetter($io, $userClass) + ); + } + + public function configureDependencies(DependencyBuilder $dependencies) + { + // This recipe depends upon Doctrine ORM, to save the token and update the user + ORMDependencyBuilder::buildDependencies($dependencies); + + $dependencies->addClassDependency( + AbstractType::class, + 'form' + ); + $dependencies->addClassDependency( + Validation::class, + 'validator' + ); + $dependencies->addClassDependency( + TwigBundle::class, + 'twig-bundle' + ); + $dependencies->addClassDependency( + SecurityBundle::class, + 'security' + ); + $dependencies->addClassDependency( + SwiftmailerBundle::class, + 'mail' + ); + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) + { + $userClass = $input->getArgument('user-class'); + $userClassNameDetails = $generator->createClassNameDetails( + '\\'.$userClass, + 'Entity\\' + ); + $tokenClassNameDetails = $generator->createClassNameDetails( + 'PasswordResetToken', + 'Entity\\' + ); + $repositoryClassNameDetails = $generator->createClassNameDetails( + 'PasswordResetTokenRepository', + 'Repository\\' + ); + + // 1) Create a new "PasswordResetToken" entity and its repository + $generator->generateClass( + $tokenClassNameDetails->getFullName(), + 'forgottenPassword/PasswordResetToken.tpl.php', + [ + 'repository_class_name' => $repositoryClassNameDetails->getFullName(), + 'user_class_name' => $userClassNameDetails->getShortName(), + 'user_full_class_name' => $userClassNameDetails->getFullName(), + ] + ); + $generator->generateClass( + $repositoryClassNameDetails->getFullName(), + 'forgottenPassword/PasswordResetTokenRepository.tpl.php', + [ + 'token_class_name' => $tokenClassNameDetails->getShortName(), + 'token_full_class_name' => $tokenClassNameDetails->getFullName(), + 'user_class_name' => $userClassNameDetails->getShortName(), + 'user_full_class_name' => $userClassNameDetails->getFullName(), + ] + ); + + // 2) Generate the "request" (email) form class + $emailField = $input->getArgument('email-field'); + $requestFormClassDetails = $this->generateRequestFormClass( + $generator, + $emailField + ); + + // 3) Generate the "new password" form class + $resettingFormClassDetails = $this->generateResettingFormClass($generator); + + // 4) Generate the controller + $controllerClassNameDetails = $generator->createClassNameDetails( + 'ForgottenPasswordController', + 'Controller\\' + ); + + $generator->generateController( + $controllerClassNameDetails->getFullName(), + 'forgottenPassword/ForgottenPasswordController.tpl.php', + [ + 'request_form_class_name' => $requestFormClassDetails->getShortName(), + 'request_form_full_class_name' => $requestFormClassDetails->getFullName(), + 'resetting_form_class_name' => $resettingFormClassDetails->getShortName(), + 'resetting_form_full_class_name' => $resettingFormClassDetails->getFullName(), + 'user_class_name' => $userClassNameDetails->getShortName(), + 'user_full_class_name' => $userClassNameDetails->getFullName(), + 'email_field' => $emailField, + 'email_getter' => $input->getArgument('email-getter'), + 'password_setter' => $input->getArgument('password-setter'), + 'login_route' => 'app_login', + 'token_class_name' => $tokenClassNameDetails->getShortName(), + 'token_full_class_name' => $tokenClassNameDetails->getFullName(), + ] + ); + + // 5) Generate the "request" template + $generator->generateFile( + 'templates/forgotten_password/request.html.twig', + 'forgottenPassword/twig_request.tpl.php', + [ + 'email_field' => $emailField, + ] + ); + + // 6) Generate the reset e-mail template + $generator->generateFile( + 'templates/forgotten_password/email.txt.twig', + 'forgottenPassword/twig_email.tpl.php', + [] + ); + + // 7) Generate the "checkEmail" template + $generator->generateFile( + 'templates/forgotten_password/check_email.html.twig', + 'forgottenPassword/twig_check_email.tpl.php', + [] + ); + + // 8) Generate the "reset" template + $generator->generateFile( + 'templates/forgotten_password/reset.html.twig', + 'forgottenPassword/twig_reset.tpl.php', + [] + ); + + $generator->writeChanges(); + $this->writeSuccessMessage($io); + + $io->text('Done! A new entity was added: PasswordResetToken. You should now generate a migration (make:migration) and run it to update your database.'); + $io->text('Next: Please review ForgottenPasswordController. Then you can add a link to "app_forgotten_password_request" path anywhere you like, typically below your login form!'); + } + + private function generateRequestFormClass(Generator $generator, string $emailField) + { + $formClassDetails = $generator->createClassNameDetails( + 'PasswordRequestFormType', + 'Form\\' + ); + + $formFields = [ + $emailField => [ + 'type' => EmailType::class, + 'options_code' => << [ + new NotBlank([ + 'message' => 'Please enter your $emailField', + ]), + ], +EOF + ], + ]; + + $this->formTypeRenderer->render( + $formClassDetails, + $formFields, + null, + [ + 'Symfony\Component\Validator\Constraints\NotBlank', + ] + ); + + return $formClassDetails; + } + + private function generateResettingFormClass(Generator $generator) + { + $formClassDetails = $generator->createClassNameDetails( + 'PasswordResettingFormType', + 'Form\\' + ); + + $formFields = [ + 'plainPassword' => [ + 'type' => RepeatedType::class, + 'options_code' => << PasswordType::class, + 'first_options' => [ + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter a password', + ]), + new Length([ + 'min' => 6, + 'minMessage' => 'Your password should be at least {{ limit }} characters', + // max length allowed by Symfony for security reasons + 'max' => 4096, + ]), + ], + 'label' => 'New password', + ], + 'second_options' => [ + 'label' => 'Repeat Password', + ], + 'invalid_message' => 'The password fields must match.', + // Instead of being set onto the object directly, + // this is read and encoded in the controller + 'mapped' => false, +EOF + ], + ]; + + $this->formTypeRenderer->render( + $formClassDetails, + $formFields, + null, + [ + 'Symfony\Component\Validator\Constraints\Length', + 'Symfony\Component\Validator\Constraints\NotBlank', + ], + [ + PasswordType::class, + ] + ); + + return $formClassDetails; + } +} diff --git a/src/Renderer/FormTypeRenderer.php b/src/Renderer/FormTypeRenderer.php index 77dee2245..0911201fb 100644 --- a/src/Renderer/FormTypeRenderer.php +++ b/src/Renderer/FormTypeRenderer.php @@ -27,7 +27,7 @@ public function __construct(Generator $generator) $this->generator = $generator; } - public function render(ClassNameDetails $formClassDetails, array $formFields, ClassNameDetails $boundClassDetails = null, array $constraintClasses = []) + public function render(ClassNameDetails $formClassDetails, array $formFields, ClassNameDetails $boundClassDetails = null, array $constraintClasses = [], array $extraUseClasses = []) { $fieldTypeUseStatements = []; $fields = []; @@ -49,7 +49,7 @@ public function render(ClassNameDetails $formClassDetails, array $formFields, Cl 'bounded_full_class_name' => $boundClassDetails ? $boundClassDetails->getFullName() : null, 'bounded_class_name' => $boundClassDetails ? $boundClassDetails->getShortName() : null, 'form_fields' => $fields, - 'field_type_use_statements' => $fieldTypeUseStatements, + 'field_type_use_statements' => array_merge($fieldTypeUseStatements, $extraUseClasses), 'constraint_use_statements' => $constraintClasses, ] ); diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index 6094f151a..15e3ec6c8 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -42,6 +42,13 @@ + + + + + + + diff --git a/src/Resources/help/MakeForgottenPassword.txt b/src/Resources/help/MakeForgottenPassword.txt new file mode 100644 index 000000000..1dc941c39 --- /dev/null +++ b/src/Resources/help/MakeForgottenPassword.txt @@ -0,0 +1,5 @@ +The %command.name% command generates a complete reset password process, including forms, controllers & templates. + +php %command.full_name% + +The command will ask for several pieces of information to build your process. diff --git a/src/Resources/skeleton/forgottenPassword/ForgottenPasswordController.tpl.php b/src/Resources/skeleton/forgottenPassword/ForgottenPasswordController.tpl.php new file mode 100644 index 000000000..3947d6dd5 --- /dev/null +++ b/src/Resources/skeleton/forgottenPassword/ForgottenPasswordController.tpl.php @@ -0,0 +1,149 @@ + + +namespace ; + +use ; +use ; +use ; +use ; +use Symfony\Bundle\FrameworkBundle\Controller\; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; + +/** + * @Route("/forgotten-password") + */ +class extends +{ + private const SESSION_TOKEN_KEY = 'forgotten_password_token'; + private const SESSION_CAN_CHECK_EMAIL = 'forgotten_password_check_email'; + + /** + * @Route("/request", name="app_forgotten_password_request") + */ + public function request(Request $request, \Swift_Mailer $mailer): Response + { + $form = $this->createForm(::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user = $this->getDoctrine()->getRepository(::class)->findOneBy([ + '' => $form->get('')->getData(), + ]); + + // Needed to be able to access next page, app_check_email + $request->getSession()->set(self::SESSION_CAN_CHECK_EMAIL, true); + + // Do not reveal whether an user account was found or not. + if (!$user) { + return $this->redirectToRoute('app_check_email'); + } + + // If User already has a valid Token, we don't want to generate a new one. + // We fail silently. + $oldTokens = $this->getDoctrine()->getRepository(::class)->findNonExpiredForUser($user); + if (count($oldTokens)) { + return $this->redirectToRoute('app_check_email'); + } + + // Generate a reset password token, that the user could use to change their password. + $resetPasswordToken = new ($user); + $this->getDoctrine()->getManager()->persist($resetPasswordToken); + $this->getDoctrine()->getManager()->flush(); + + $message = (new \Swift_Message('Your password reset request')) + ->setFrom(['noreply@mydomain.com' => 'Noreply']) + ->setTo($user->()) + ->setBody($this->renderView('forgotten_password/email.txt.twig', [ + 'token' => $resetPasswordToken, + ])) + ; + $mailer->send($message); + + return $this->redirectToRoute('app_check_email'); + } + + return $this->render('forgotten_password/request.html.twig', [ + 'requestForm' => $form->createView(), + ]); + } + + /** + * @Route("/check-email", name="app_check_email") + */ + public function checkEmail(SessionInterface $session) + { + // We prevent users from directly accessing this page + if (!$session->get(self::SESSION_CAN_CHECK_EMAIL)) { + return $this->redirectToRoute('app_forgotten_password_request'); + } + + $session->remove(self::SESSION_CAN_CHECK_EMAIL); + + return $this->render('forgotten_password/check_email.html.twig', [ + 'tokenLifetime' => ::LIFETIME_HOURS, + ]); + } + + /** + * @Route("/reset/{tokenAndSelector}", name="app_reset_password") + */ + public function reset(Request $request, UserPasswordEncoderInterface $passwordEncoder, $tokenAndSelector = null): Response + { + if ($tokenAndSelector) { + // We store token in session and remove it from the URL, + // to avoid any leak if someone get to know the URL (AJAX requests, Analytics...). + $request->getSession()->set(self::SESSION_TOKEN_KEY, $tokenAndSelector); + + return $this->redirectToRoute('app_reset_password'); + } + + $tokenAndSelector = $request->getSession()->get(self::SESSION_TOKEN_KEY); + if (!$tokenAndSelector) { + throw $this->createNotFoundException(); + } + + $passwordResetToken = $this->getDoctrine()->getRepository(PasswordResetToken::class)->findOneBy([ + 'selector' => substr($tokenAndSelector, 0, PasswordResetToken::SELECTOR_LENGTH), + ]); + + if (!$passwordResetToken) { + throw $this->createNotFoundException(); + } + + if ($passwordResetToken->isExpired() || !$passwordResetToken->isTokenEquals(substr($tokenAndSelector, PasswordResetToken::SELECTOR_LENGTH))) { + $this->getDoctrine()->getManager()->remove($passwordResetToken); + $this->getDoctrine()->getManager()->flush(); + + throw $this->createNotFoundException(); + } + + $form = $this->createForm(::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // A PasswordResetToken should be used only once, remove it. + $this->getDoctrine()->getManager()->remove($passwordResetToken); + + // Encode the plain password, and set it. + $passwordResetToken->getUser()->( + $passwordEncoder->encodePassword( + $passwordResetToken->getUser(), + $form->get('plainPassword')->getData() + ) + ); + + $this->getDoctrine()->getManager()->flush(); + + // TODO: please check the login route + return $this->redirectToRoute(''); + } + + return $this->render('forgotten_password/reset.html.twig', [ + 'resetForm' => $form->createView(), + ]); + } +} diff --git a/src/Resources/skeleton/forgottenPassword/PasswordResetToken.tpl.php b/src/Resources/skeleton/forgottenPassword/PasswordResetToken.tpl.php new file mode 100644 index 000000000..b9a401134 --- /dev/null +++ b/src/Resources/skeleton/forgottenPassword/PasswordResetToken.tpl.php @@ -0,0 +1,87 @@ + + +namespace ; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity(repositoryClass="") + */ +class + +{ + const LIFETIME_HOURS = 24; + const SELECTOR_LENGTH = 20; // in chars + + /** + * @ORM\Id() + * @ORM\GeneratedValue() + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string") + */ + private $selector; + + /** + * @ORM\Column(type="string") + */ + private $token; + + /** + * @ORM\Column(type="datetime_immutable") + */ + private $requestedAt; + + /** + * @ORM\ManyToOne(targetEntity="") + */ + private $user; + + private $plainToken; + + public function __construct(User $user) + { + $this->requestedAt = new \DateTimeImmutable('now'); + $this->selector = strtr(base64_encode(random_bytes(self::SELECTOR_LENGTH * 3 / 4)), '+/', '-_'); + $this->plainToken = strtr(base64_encode(random_bytes(18)), '+/', '-_'); + $this->token = password_hash($this->plainToken, PASSWORD_DEFAULT); + $this->user = $user; + } + + public function getId() + { + return $this->id; + } + + public function getAsString(): string + { + if (!$this->selector || !$this->plainToken) { + throw new \Exception('You can get PasswordResetToken as a string only immediately after creation.'); + } + + return $this->selector.$this->plainToken; + } + + public function getUser(): + + { + return $this->user; + } + + public function isTokenEquals(string $token): bool + { + return password_verify($token, $this->token); + } + + public function isExpired(): bool + { + if (($this->requestedAt->getTimestamp() + self::LIFETIME_HOURS * 3600) <= time()) { + return true; + } + + return false; + } +} diff --git a/src/Resources/skeleton/forgottenPassword/PasswordResetTokenRepository.tpl.php b/src/Resources/skeleton/forgottenPassword/PasswordResetTokenRepository.tpl.php new file mode 100644 index 000000000..7ba87b13d --- /dev/null +++ b/src/Resources/skeleton/forgottenPassword/PasswordResetTokenRepository.tpl.php @@ -0,0 +1,40 @@ + + +namespace App\Repository; + +use ; +use ; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Common\Persistence\ManagerRegistry; + +/** + * @method |null find($id, $lockMode = null, $lockVersion = null) + * @method |null findOneBy(array $criteria, array $orderBy = null) + * @method [] findAll() + * @method [] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ::class); + } + + public function findNonExpiredForUser( $user): array + { + // We calculate the oldest datetime a valid token could have generated at + $tokenLifetime = new \DateInterval(sprintf('PT%sH', ::LIFETIME_HOURS)); + $minDateTime = (new \DateTimeImmutable('now'))->sub($tokenLifetime); + + return $this->createQueryBuilder('t') + ->where('t.user = :user') + ->andWhere('t.requestedAt >= :minDateTime') + ->setParameters([ + 'minDateTime' => $minDateTime, + 'user' => $user, + ]) + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Resources/skeleton/forgottenPassword/twig_check_email.tpl.php b/src/Resources/skeleton/forgottenPassword/twig_check_email.tpl.php new file mode 100644 index 000000000..3c30e963f --- /dev/null +++ b/src/Resources/skeleton/forgottenPassword/twig_check_email.tpl.php @@ -0,0 +1,6 @@ +getHeadPrintCode('Check your e-mail address'); ?> + +{% block body %} +

An email has been sent. It contains a link you must click to reset your password. This link will expire in {{ tokenLifetime }} hours.

+

If you don't get an email please check your spam folder or try again.

+{% endblock %} diff --git a/src/Resources/skeleton/forgottenPassword/twig_email.tpl.php b/src/Resources/skeleton/forgottenPassword/twig_email.tpl.php new file mode 100644 index 000000000..d8db5bfab --- /dev/null +++ b/src/Resources/skeleton/forgottenPassword/twig_email.tpl.php @@ -0,0 +1,7 @@ +Hello, + +To reset your password, please visit {{ url('app_reset_password', {tokenAndSelector: token.asString}) }} +This link will expire in {{ constant('LIFETIME_HOURS', token) }} hours. + +Regards, +the Team. diff --git a/src/Resources/skeleton/forgottenPassword/twig_request.tpl.php b/src/Resources/skeleton/forgottenPassword/twig_request.tpl.php new file mode 100644 index 000000000..4664ab33f --- /dev/null +++ b/src/Resources/skeleton/forgottenPassword/twig_request.tpl.php @@ -0,0 +1,11 @@ +getHeadPrintCode('Recover your password'); ?> + +{% block body %} +

Recover your password

+ + {{ form_start(requestForm) }} + {{ form_row(requestForm.) }} + + + {{ form_end(requestForm) }} +{% endblock %} diff --git a/src/Resources/skeleton/forgottenPassword/twig_reset.tpl.php b/src/Resources/skeleton/forgottenPassword/twig_reset.tpl.php new file mode 100644 index 000000000..3dfbb9fc0 --- /dev/null +++ b/src/Resources/skeleton/forgottenPassword/twig_reset.tpl.php @@ -0,0 +1,11 @@ +getHeadPrintCode('Reset your password'); ?> + +{% block body %} +

Reset your password

+ + {{ form_start(resetForm) }} + {{ form_row(resetForm.plainPassword) }} + + + {{ form_end(resetForm) }} +{% endblock %} diff --git a/src/Security/InteractiveSecurityHelper.php b/src/Security/InteractiveSecurityHelper.php index 1b079670f..4e87d22c8 100644 --- a/src/Security/InteractiveSecurityHelper.php +++ b/src/Security/InteractiveSecurityHelper.php @@ -145,6 +145,24 @@ public function guessUserNameField(SymfonyStyle $io, string $userClass, array $p ); } + public function guessEmailField(SymfonyStyle $io, string $userClass): string + { + if (property_exists($userClass, 'email')) { + return 'email'; + } + + $classProperties = []; + $reflectionClass = new \ReflectionClass($userClass); + foreach ($reflectionClass->getProperties() as $property) { + $classProperties[] = $property->name; + } + + return $io->choice( + sprintf('Which field on your %s class holds the email address?', $userClass), + $classProperties + ); + } + public function guessPasswordField(SymfonyStyle $io, string $userClass): string { if (property_exists($userClass, 'password')) { @@ -184,4 +202,42 @@ public function getAuthenticatorClasses(array $firewallData): array return $authenticatorClasses; } + + public function guessPasswordSetter(SymfonyStyle $io, string $userClass): string + { + $reflectionClass = new \ReflectionClass($userClass); + + if ($reflectionClass->hasMethod('setPassword')) { + return 'setPassword'; + } + + $classMethods = []; + foreach ($reflectionClass->getMethods() as $method) { + $classMethods[] = $method->name; + } + + return $io->choice( + sprintf('Which method on your %s class can be used to set the encoded password (e.g. setPassword())?', $userClass), + $classMethods + ); + } + + public function guessEmailGetter(SymfonyStyle $io, string $userClass): string + { + $reflectionClass = new \ReflectionClass($userClass); + + if ($reflectionClass->hasMethod('getEmail')) { + return 'getEmail'; + } + + $classMethods = []; + foreach ($reflectionClass->getMethods() as $method) { + $classMethods[] = $method->name; + } + + return $io->choice( + sprintf('Which method on your %s class can be used to get the email address (e.g. getEmail())?', $userClass), + $classMethods + ); + } } diff --git a/tests/Maker/FunctionalTest.php b/tests/Maker/FunctionalTest.php index 87165c0b2..93e294755 100644 --- a/tests/Maker/FunctionalTest.php +++ b/tests/Maker/FunctionalTest.php @@ -23,6 +23,7 @@ use Symfony\Bundle\MakerBundle\Maker\MakeCrud; use Symfony\Bundle\MakerBundle\Maker\MakeEntity; use Symfony\Bundle\MakerBundle\Maker\MakeFixtures; +use Symfony\Bundle\MakerBundle\Maker\MakeForgottenPassword; use Symfony\Bundle\MakerBundle\Maker\MakeForm; use Symfony\Bundle\MakerBundle\Maker\MakeFunctionalTest; use Symfony\Bundle\MakerBundle\Maker\MakeMigration; @@ -797,6 +798,19 @@ function (string $output, string $directory) { } ), ]; + + yield 'forgotten_password' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeForgottenPassword::class), + [ + 'App\\Entity\\User', + // email field guessed + // email getter guessed + // password setter guessed + ]) + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeForgottenPassword') + ->configureDatabase() + ->updateSchemaAfterCommand() + ]; } public function getCommandEntityTests() diff --git a/tests/Security/InteractiveSecurityHelperTest.php b/tests/Security/InteractiveSecurityHelperTest.php index 37d9dd2ac..81c76ed6a 100644 --- a/tests/Security/InteractiveSecurityHelperTest.php +++ b/tests/Security/InteractiveSecurityHelperTest.php @@ -224,6 +224,111 @@ public function getUsernameFieldsTest() ['username', 'email'], ]; } + + /** + * @dataProvider guessEmailFieldTest + */ + public function testGuessEmailField(string $expectedEmailField, bool $fieldAutomaticallyGuessed, string $class = '', array $choices = []) + { + /** @var SymfonyStyle|\PHPUnit_Framework_MockObject_MockObject $io */ + $io = $this->createMock(SymfonyStyle::class); + $io->expects($this->exactly(true === $fieldAutomaticallyGuessed ? 0 : 1)) + ->method('choice') + ->with(sprintf('Which field on your %s class holds the email address?', $class), $choices, null) + ->willReturn($expectedEmailField); + + $interactiveSecurityHelper = new InteractiveSecurityHelper(); + $this->assertEquals( + $expectedEmailField, + $interactiveSecurityHelper->guessEmailField($io, $class) + ); + } + + public function guessEmailFieldTest() + { + yield 'guess_fixture_class' => [ + 'expectedEmailField' => 'email', + true, + FixtureClass::class + ]; + + yield 'guess_fixture_class_2' => [ + 'expectedEmailField' => 'myEmail', + false, + FixtureClass4::class, + ['myEmail'], + ]; + } + + /** + * @dataProvider guessPasswordSetterTest + */ + public function testGuessPasswordSetter(string $expectedPasswordSetter, bool $automaticallyGuessed, string $class = '', array $choices = []) + { + /** @var SymfonyStyle|\PHPUnit_Framework_MockObject_MockObject $io */ + $io = $this->createMock(SymfonyStyle::class); + $io->expects($this->exactly(true === $automaticallyGuessed ? 0 : 1)) + ->method('choice') + ->with(sprintf('Which method on your %s class can be used to set the encoded password (e.g. setPassword())?', $class), $choices, null) + ->willReturn($expectedPasswordSetter); + + $interactiveSecurityHelper = new InteractiveSecurityHelper(); + $this->assertEquals( + $expectedPasswordSetter, + $interactiveSecurityHelper->guessPasswordSetter($io, $class) + ); + } + + public function guessPasswordSetterTest() + { + yield 'guess_fixture_class' => [ + 'expectedPasswordSetter' => 'setPassword', + true, + FixtureClass5::class + ]; + + yield 'guess_fixture_class_2' => [ + 'expectedPasswordSetter' => 'setEncodedPassword', + false, + FixtureClass6::class, + ['setEncodedPassword'], + ]; + } + + /** + * @dataProvider guessEmailGetterTest + */ + public function testGuessEmailGetter(string $expectedEmailGetter, bool $automaticallyGuessed, string $class = '', array $choices = []) + { + /** @var SymfonyStyle|\PHPUnit_Framework_MockObject_MockObject $io */ + $io = $this->createMock(SymfonyStyle::class); + $io->expects($this->exactly(true === $automaticallyGuessed ? 0 : 1)) + ->method('choice') + ->with(sprintf('Which method on your %s class can be used to get the email address (e.g. getEmail())?', $class), $choices, null) + ->willReturn($expectedEmailGetter); + + $interactiveSecurityHelper = new InteractiveSecurityHelper(); + $this->assertEquals( + $expectedEmailGetter, + $interactiveSecurityHelper->guessEmailGetter($io, $class) + ); + } + + public function guessEmailGetterTest() + { + yield 'guess_fixture_class' => [ + 'expectedPasswordSetter' => 'getEmail', + true, + FixtureClass7::class + ]; + + yield 'guess_fixture_class_2' => [ + 'expectedPasswordSetter' => 'getMyEmail', + false, + FixtureClass8::class, + ['getMyEmail'], + ]; + } } class FixtureClass @@ -241,3 +346,28 @@ class FixtureClass3 private $username; private $email; } + +class FixtureClass4 +{ + private $myEmail; +} + +class FixtureClass5 +{ + public function setPassword() {} +} + +class FixtureClass6 +{ + public function setEncodedPassword() {} +} + +class FixtureClass7 +{ + public function getEmail() {} +} + +class FixtureClass8 +{ + public function getMyEmail() {} +} diff --git a/tests/fixtures/MakeForgottenPassword/config/packages/security.yml b/tests/fixtures/MakeForgottenPassword/config/packages/security.yml new file mode 100644 index 000000000..0cdc923bd --- /dev/null +++ b/tests/fixtures/MakeForgottenPassword/config/packages/security.yml @@ -0,0 +1,3 @@ +security: + encoders: + App\Entity\User: bcrypt diff --git a/tests/fixtures/MakeForgottenPassword/config/packages/swiftmailer.yaml b/tests/fixtures/MakeForgottenPassword/config/packages/swiftmailer.yaml new file mode 100644 index 000000000..1a179ae1e --- /dev/null +++ b/tests/fixtures/MakeForgottenPassword/config/packages/swiftmailer.yaml @@ -0,0 +1,4 @@ +swiftmailer: + spool: + type: file + path: '%kernel.project_dir%/var/spool' diff --git a/tests/fixtures/MakeForgottenPassword/config/routes.yaml b/tests/fixtures/MakeForgottenPassword/config/routes.yaml new file mode 100644 index 000000000..b6700862b --- /dev/null +++ b/tests/fixtures/MakeForgottenPassword/config/routes.yaml @@ -0,0 +1,2 @@ +app_login: + path: / diff --git a/tests/fixtures/MakeForgottenPassword/src/Entity/User.php b/tests/fixtures/MakeForgottenPassword/src/Entity/User.php new file mode 100644 index 000000000..bd6023387 --- /dev/null +++ b/tests/fixtures/MakeForgottenPassword/src/Entity/User.php @@ -0,0 +1,104 @@ +id; + } + + public function getEmail() + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + public function getSalt() + { + } + + public function eraseCredentials() + { + } +} diff --git a/tests/fixtures/MakeForgottenPassword/tests/ForgottenPasswordControllerTest.php b/tests/fixtures/MakeForgottenPassword/tests/ForgottenPasswordControllerTest.php new file mode 100644 index 000000000..647881ad7 --- /dev/null +++ b/tests/fixtures/MakeForgottenPassword/tests/ForgottenPasswordControllerTest.php @@ -0,0 +1,307 @@ +getContainer() + ->get('doctrine') + ->getManager(); + $em->createQuery('DELETE FROM App\\Entity\\PasswordResetToken t') + ->execute(); + $em->createQuery('DELETE FROM App\\Entity\\User u') + ->execute(); + + $user = new User(); + $user->setEmail('foo@example.com'); + $user->setPassword('randompassword'); + + $em->persist($user); + $em->flush(); + + $client = static::createClient(); + + $spoolDir = $client->getContainer()->getParameter('swiftmailer.spool.default.file.path'); + $filesystem = new Filesystem(); + $filesystem->remove($spoolDir); + + // Start of our test: we request a password reset e-mail + $crawler = $client->request('GET', '/forgotten-password/request'); + $form = $crawler->selectButton('Send e-mail')->form(); + $form['password_request_form[email]'] = 'foo@example.com'; + $client->submit($form); + $this->assertSame(302, $client->getResponse()->getStatusCode()); + + // We test the e-mail is sent, looking at the spool + $finder = new Finder(); + $this->assertEquals(1, $finder->in($spoolDir)->files()->count()); + foreach ($finder as $file) { + $message = unserialize($file->getContents()); + $this->assertInstanceOf(\Swift_Message::class, $message); + $this->assertEquals('Your password reset request', $message->getSubject()); + } + + // We continue the browsing... + $client->followRedirect(); + $this->assertSame(200, $client->getResponse()->getStatusCode()); + $this->assertContains('An email has been sent.', $client->getResponse()->getContent()); + } + + public function testRequestNotFound() + { + self::bootKernel(); + $client = static::createClient(); + + $spoolDir = $client->getContainer()->getParameter('swiftmailer.spool.default.file.path'); + $filesystem = new Filesystem(); + $filesystem->remove($spoolDir); + + // Start of our test: we request a password reset e-mail + $crawler = $client->request('GET', '/forgotten-password/request'); + $form = $crawler->selectButton('Send e-mail')->form(); + $form['password_request_form[email]'] = 'anotheremail@example.com'; + $client->submit($form); + $this->assertSame(302, $client->getResponse()->getStatusCode()); + + // No emails should be sent + $finder = new Finder(); + $this->assertEquals(0, $finder->in($spoolDir)->files()->count()); + + // We continue the browsing... + $client->followRedirect(); + // We should get the same message, the application should not disclose the user does not exist. + $this->assertSame(200, $client->getResponse()->getStatusCode()); + $this->assertContains('An email has been sent.', $client->getResponse()->getContent()); + } + + public function testRequestRetryLimit() + { + self::bootKernel(); + + /** @var EntityManager $em */ + $em = self::$kernel->getContainer() + ->get('doctrine') + ->getManager(); + $em->createQuery('DELETE FROM App\\Entity\\PasswordResetToken t') + ->execute(); + $em->createQuery('DELETE FROM App\\Entity\\User u') + ->execute(); + + $user = new User(); + $user->setEmail('bar@example.com'); + $user->setPassword('randompassword'); + + $em->persist($user); + $em->flush(); + + $client = static::createClient(); + + $spoolDir = $client->getContainer()->getParameter('swiftmailer.spool.default.file.path'); + $filesystem = new Filesystem(); + $filesystem->remove($spoolDir); + + // Start of our test: we request a password reset e-mail + $crawler = $client->request('GET', '/forgotten-password/request'); + $form = $crawler->selectButton('Send e-mail')->form(); + $form['password_request_form[email]'] = 'bar@example.com'; + $client->submit($form); + $this->assertSame(302, $client->getResponse()->getStatusCode()); + + // We test the e-mail is sent, looking at the spool + $finder = new Finder(); + $this->assertEquals(1, $finder->in($spoolDir)->files()->count()); + foreach ($finder as $file) { + $message = unserialize($file->getContents()); + $this->assertInstanceOf(\Swift_Message::class, $message); + $this->assertEquals('Your password reset request', $message->getSubject()); + } + + // We try to request again + $crawler = $client->request('GET', '/forgotten-password/request'); + $form = $crawler->selectButton('Send e-mail')->form(); + $form['password_request_form[email]'] = 'bar@example.com'; + $client->submit($form); + $this->assertSame(302, $client->getResponse()->getStatusCode()); + + // No second email should be sent, as bar@example.com still had a valid token + $this->assertEquals(1, $finder->count()); + } + + public function testCheckEmailNotAccessibleDirectly() + { + self::bootKernel(); + $client = static::createClient(); + + $client->request('GET', '/forgotten-password/check-email'); + + // We are redirected to the request page + $this->assertSame(302, $client->getResponse()->getStatusCode()); + // We continue the browsing... + $client->followRedirect(); + + $this->assertSame(200, $client->getResponse()->getStatusCode()); + } + + public function testResetSuccessful() + { + self::bootKernel(); + + /** @var EntityManager $em */ + $em = self::$kernel->getContainer() + ->get('doctrine') + ->getManager(); + $em->createQuery('DELETE FROM App\\Entity\\PasswordResetToken t') + ->execute(); + $em->createQuery('DELETE FROM App\\Entity\\User u') + ->execute(); + + $user = new User(); + $user->setEmail('foo@example.com'); + $user->setPassword('randompassword'); + + $token = new PasswordResetToken($user); + + $em->persist($user); + $em->persist($token); + $em->flush(); + + $client = static::createClient(); + + // Start of our test: we go to the reset password form + $client->request('GET', '/forgotten-password/reset/'.$token->getAsString()); + // We are redirected to the same page + $this->assertSame(302, $client->getResponse()->getStatusCode()); + + // We continue the browsing... + $crawler = $client->followRedirect(); + $this->assertSame(200, $client->getResponse()->getStatusCode()); + + // We fill in a new password + $form = $crawler->selectButton('Reset password')->form(); + $form['password_resetting_form[plainPassword][first]'] = 'newpassword'; + $form['password_resetting_form[plainPassword][second]'] = 'newpassword'; + $client->submit($form); + + // It is saved, we are redirected to login + $this->assertSame(302, $client->getResponse()->getStatusCode()); + + // Token was removed from DB + $this->assertCount(0, $em->getRepository(PasswordResetToken::class)->findBy([ + 'user' => $user, + ])); + } + + + public function testResetWrongSelector() + { + self::bootKernel(); + $client = static::createClient(); + + $client->request('GET', '/forgotten-password/reset/randomselectorandtoken'); + + // We are redirected to the same page + $this->assertSame(302, $client->getResponse()->getStatusCode()); + // We continue the browsing... + $client->followRedirect(); + + $this->assertSame(404, $client->getResponse()->getStatusCode()); + } + + public function testResetWrongToken() + { + self::bootKernel(); + + /** @var EntityManager $em */ + $em = self::$kernel->getContainer() + ->get('doctrine') + ->getManager(); + $em->createQuery('DELETE FROM App\\Entity\\PasswordResetToken t') + ->execute(); + $em->createQuery('DELETE FROM App\\Entity\\User u') + ->execute(); + + $user = new User(); + $user->setEmail('foo@example.com'); + $user->setPassword('randompassword'); + + $token = new PasswordResetToken($user); + + $em->persist($user); + $em->persist($token); + $em->flush(); + + $client = static::createClient(); + + // Start of our test: we go to the reset password form + $client->request('GET', '/forgotten-password/reset/'.$token->getAsString().'wrong'); + + // We are redirected to the same page + $this->assertSame(302, $client->getResponse()->getStatusCode()); + // We continue the browsing... + $client->followRedirect(); + + $this->assertSame(404, $client->getResponse()->getStatusCode()); + + // Token was removed from DB + $this->assertCount(0, $em->getRepository(PasswordResetToken::class)->findBy([ + 'user' => $user, + ])); + } + + public function testResetExpired() + { + self::bootKernel(); + + /** @var EntityManager $em */ + $em = self::$kernel->getContainer() + ->get('doctrine') + ->getManager(); + $em->createQuery('DELETE FROM App\\Entity\\PasswordResetToken t') + ->execute(); + $em->createQuery('DELETE FROM App\\Entity\\User u') + ->execute(); + + $user = new User(); + $user->setEmail('foo@example.com'); + $user->setPassword('randompassword'); + + $token = new PasswordResetToken($user); + // We change time in token + $reflection = new \ReflectionProperty(PasswordResetToken::class, 'requestedAt'); + $reflection->setAccessible(true); + $reflection->setValue($token, new \DateTimeImmutable('-2 days')); + + $em->persist($user); + $em->persist($token); + $em->flush(); + + $client = static::createClient(); + + // Start of our test: we go to the reset password form + $client->request('GET', '/forgotten-password/reset/'.$token->getAsString()); + + // We are redirected to the same page + $this->assertSame(302, $client->getResponse()->getStatusCode()); + // We continue the browsing... + $client->followRedirect(); + + $this->assertSame(404, $client->getResponse()->getStatusCode()); + + // Token was removed from DB + $this->assertCount(0, $em->getRepository(PasswordResetToken::class)->findBy([ + 'user' => $user, + ])); + } +}