From ebdb2274aeb9047b4cbdddda859a0d989358e5c6 Mon Sep 17 00:00:00 2001 From: Jesse Rushlow Date: Wed, 6 Jan 2021 11:35:27 -0500 Subject: [PATCH] [make:registration] allow email verification without authentication --- src/Maker/MakeRegistrationForm.php | 90 +++++++++++++----- src/Resources/config/makers.xml | 1 + .../RegistrationController.tpl.php | 27 +++++- .../verifyEmail/EmailVerifier.tpl.php | 5 + tests/Maker/MakeRegistrationFormTest.php | 27 +++++- .../config/packages/mailer.yaml | 3 + .../config/packages/security.yaml | 20 ++++ .../src/Controller/MyController.php | 18 ++++ .../src/Entity/User.php | 93 +++++++++++++++++++ .../src/Repository/UserRepository.php | 21 +++++ .../tests/VerifyEmailTest.php | 41 ++++++++ 11 files changed, 319 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/config/packages/mailer.yaml create mode 100644 tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/config/packages/security.yaml create mode 100644 tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Controller/MyController.php create mode 100644 tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Entity/User.php create mode 100644 tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Repository/UserRepository.php create mode 100644 tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/tests/VerifyEmailTest.php diff --git a/src/Maker/MakeRegistrationForm.php b/src/Maker/MakeRegistrationForm.php index 6718f87c5..66b03df53 100644 --- a/src/Maker/MakeRegistrationForm.php +++ b/src/Maker/MakeRegistrationForm.php @@ -16,6 +16,7 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper; use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; use Symfony\Bundle\MakerBundle\FileManager; use Symfony\Bundle\MakerBundle\Generator; @@ -55,11 +56,14 @@ final class MakeRegistrationForm extends AbstractMaker private $router; - public function __construct(FileManager $fileManager, FormTypeRenderer $formTypeRenderer, RouterInterface $router) + private $doctrineHelper; + + public function __construct(FileManager $fileManager, FormTypeRenderer $formTypeRenderer, RouterInterface $router, DoctrineHelper $doctrineHelper) { $this->fileManager = $fileManager; $this->formTypeRenderer = $formTypeRenderer; $this->router = $router; + $this->doctrineHelper = $doctrineHelper; } public static function getCommandName(): string @@ -83,6 +87,7 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma ->addArgument('username-field') ->addArgument('password-field') ->addArgument('will-verify-email') + ->addArgument('verify-email-anonymously') ->addArgument('id-getter') ->addArgument('email-getter') ->addArgument('from-email-address') @@ -138,9 +143,22 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma $input->setArgument('will-verify-email', $willVerify); + // This must be preset to true to avoid code being generated if $willVerify === false + $input->setArgument('verify-email-anonymously', false); + if ($willVerify) { $this->checkComponentsExist($io); + $emailText[] = 'By default, users are required to be authenticated when they click the verification link that is emailed to them.'; + $emailText[] = 'This prevents the user from registering on their laptop, then clicking the link on their phone, without'; + $emailText[] = 'having to log in. To allow multi device email verification, we can embed a user id in the verification link.'; + $io->text($emailText); + $io->newLine(); + $input->setArgument( + 'verify-email-anonymously', + $io->confirm('Would you like to include the user id in the verification link to allow anonymous email verification?', false) + ); + $input->setArgument('id-getter', $interactiveSecurityHelper->guessIdGetter($io, $userClass)); $input->setArgument('email-getter', $interactiveSecurityHelper->guessEmailGetter($io, $userClass, 'email')); @@ -219,6 +237,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_var' => '$manager', + ]; + + $userRepository = $userDoctrineDetails->getRepositoryClass(); + + if (null !== $userRepository) { + $userRepoClassDetails = $generator->createClassNameDetails('\\'.$userRepository, 'Repository\\', 'Repository'); + + $userRepoVars = [ + 'repository_full_class_name' => $userRepoClassDetails->getFullName(), + 'repository_class_name' => $userRepoClassDetails->getShortName(), + 'repository_var' => sprintf('$%s', lcfirst($userRepoClassDetails->getShortName())), + ]; + } + $verifyEmailServiceClassNameDetails = $generator->createClassNameDetails( 'EmailVerifier', 'Security\\' @@ -228,10 +266,13 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $generator->generateClass( $verifyEmailServiceClassNameDetails->getFullName(), 'verifyEmail/EmailVerifier.tpl.php', - [ - 'id_getter' => $input->getArgument('id-getter'), - 'email_getter' => $input->getArgument('email-getter'), - ] + array_merge([ + 'id_getter' => $input->getArgument('id-getter'), + 'email_getter' => $input->getArgument('email-getter'), + 'verify_email_anonymously' => $input->getArgument('verify-email-anonymously'), + ], + $userRepoVars + ) ); $generator->generateTemplate( @@ -258,24 +299,27 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $generator->generateController( $controllerClassNameDetails->getFullName(), 'registration/RegistrationController.tpl.php', - [ - 'route_path' => '/register', - 'route_name' => 'app_register', - 'form_class_name' => $formClassDetails->getShortName(), - 'form_full_class_name' => $formClassDetails->getFullName(), - 'user_class_name' => $userClassNameDetails->getShortName(), - 'user_full_class_name' => $userClassNameDetails->getFullName(), - 'password_field' => $input->getArgument('password-field'), - 'will_verify_email' => $input->getArgument('will-verify-email'), - 'verify_email_security_service' => $verifyEmailServiceClassNameDetails->getFullName(), - 'from_email' => $input->getArgument('from-email-address'), - 'from_email_name' => $input->getArgument('from-email-name'), - 'email_getter' => $input->getArgument('email-getter'), - 'authenticator_class_name' => $authenticatorClassName ? Str::getShortClassName($authenticatorClassName) : null, - 'authenticator_full_class_name' => $authenticatorClassName, - 'firewall_name' => $input->getOption('firewall-name'), - 'redirect_route_name' => $input->getOption('redirect-route-name'), - ] + array_merge([ + 'route_path' => '/register', + 'route_name' => 'app_register', + 'form_class_name' => $formClassDetails->getShortName(), + 'form_full_class_name' => $formClassDetails->getFullName(), + 'user_class_name' => $userClassNameDetails->getShortName(), + 'user_full_class_name' => $userClassNameDetails->getFullName(), + 'password_field' => $input->getArgument('password-field'), + 'will_verify_email' => $input->getArgument('will-verify-email'), + 'verify_email_anonymously' => $input->getArgument('verify-email-anonymously'), + 'verify_email_security_service' => $verifyEmailServiceClassNameDetails->getFullName(), + 'from_email' => $input->getArgument('from-email-address'), + 'from_email_name' => $input->getArgument('from-email-name'), + 'email_getter' => $input->getArgument('email-getter'), + 'authenticator_class_name' => $authenticatorClassName ? Str::getShortClassName($authenticatorClassName) : null, + 'authenticator_full_class_name' => $authenticatorClassName, + 'firewall_name' => $input->getOption('firewall-name'), + 'redirect_route_name' => $input->getOption('redirect-route-name'), + ], + $userRepoVars + ) ); // 3) Generate the template diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index e17b72538..eafbed7b8 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -71,6 +71,7 @@ + diff --git a/src/Resources/skeleton/registration/RegistrationController.tpl.php b/src/Resources/skeleton/registration/RegistrationController.tpl.php index 8b87b5253..9f9f3bca4 100644 --- a/src/Resources/skeleton/registration/RegistrationController.tpl.php +++ b/src/Resources/skeleton/registration/RegistrationController.tpl.php @@ -11,6 +11,9 @@ use ; + +use ; + use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\; @@ -102,13 +105,33 @@ public function register(Request $request, UserPasswordEncoderInterface $passwor * @Route("/verify/email", name="app_verify_email") */ - public function verifyUserEmail(Request $request): Response + public function verifyUserEmail(Request $request): Response { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + $id = $request->get('id'); + + if (null === $id) { + return $this->redirectToRoute('app_register'); + } + + + $repository = $manager->getRepository(::class); + $user = $repository->find($id); + + + ->find($id); + + + if (null === $user) { + return $this->redirectToRoute('app_register'); + } + // validate email confirmation link, sets User::isVerified=true and persists try { - $this->emailVerifier->handleEmailConfirmation($request, $this->getUser()); + $this->emailVerifier->handleEmailConfirmation($request, getUser()' ?>); } catch (VerifyEmailExceptionInterface $exception) { $this->addFlash('verify_email_error', $exception->getReason()); diff --git a/src/Resources/skeleton/verifyEmail/EmailVerifier.tpl.php b/src/Resources/skeleton/verifyEmail/EmailVerifier.tpl.php index c355696f8..e9ff81381 100644 --- a/src/Resources/skeleton/verifyEmail/EmailVerifier.tpl.php +++ b/src/Resources/skeleton/verifyEmail/EmailVerifier.tpl.php @@ -28,7 +28,12 @@ public function sendEmailConfirmation(string $verifyEmailRouteName, UserInterfac $signatureComponents = $this->verifyEmailHelper->generateSignature( $verifyEmailRouteName, $user->(), + + $user->(), + ['id' => $user->getId()] + $user->() + ); $context = $email->getContext(); diff --git a/tests/Maker/MakeRegistrationFormTest.php b/tests/Maker/MakeRegistrationFormTest.php index 0470541fb..1f057bedf 100644 --- a/tests/Maker/MakeRegistrationFormTest.php +++ b/tests/Maker/MakeRegistrationFormTest.php @@ -79,7 +79,8 @@ public function getTestDetails() $this->getMakerInstance(MakeRegistrationForm::class), [ 'n', // add UniqueEntity - 'y', // no verify user + 'y', // verify user + 'y', // require authentication to verify user email 'jr@rushlow.dev', // from email address 'SymfonyCasts', // From Name 'n', // no authenticate after @@ -110,7 +111,8 @@ function (string $output, string $directory) { $this->getMakerInstance(MakeRegistrationForm::class), [ 'n', // add UniqueEntity - 'y', // no verify user + 'y', // verify user + 'n', // require authentication to verify user email 'jr@rushlow.dev', // from email address 'SymfonyCasts', // From Name '', // yes authenticate after @@ -125,5 +127,26 @@ function (string $output, string $directory) { ->addExtraDependencies('symfony/web-profiler-bundle') ->addExtraDependencies('mailer'), ]; + + yield 'verify_email_no_auth_functional_test' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeRegistrationForm::class), + [ + 'n', // add UniqueEntity + 'y', // verify user's email + 'y', // require authentication to verify user email + 'jr@rushlow.dev', // from email address + 'SymfonyCasts', // From Name + '', // yes authenticate after + 'main', // redirect to route after registration + ]) + ->setRequiredPhpVersion(70200) + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest') + ->addExtraDependencies('symfonycasts/verify-email-bundle') + ->configureDatabase() + ->updateSchemaAfterCommand() + // needed for internal functional test + ->addExtraDependencies('symfony/web-profiler-bundle') + ->addExtraDependencies('mailer'), + ]; } } diff --git a/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/config/packages/mailer.yaml b/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/config/packages/mailer.yaml new file mode 100644 index 000000000..6da81a36b --- /dev/null +++ b/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/config/packages/mailer.yaml @@ -0,0 +1,3 @@ +framework: + mailer: + dsn: 'null://null' \ No newline at end of file diff --git a/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/config/packages/security.yaml b/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/config/packages/security.yaml new file mode 100644 index 000000000..2fa285946 --- /dev/null +++ b/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/config/packages/security.yaml @@ -0,0 +1,20 @@ +security: + encoders: + App\Entity\User: bcrypt + + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + app_user_provider: + entity: + class: App\Entity\User + property: email + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + anonymous: true +# guard: +# authenticators: +# - App\Security\StubAuthenticator \ No newline at end of file diff --git a/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Controller/MyController.php b/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Controller/MyController.php new file mode 100644 index 000000000..72d6fab61 --- /dev/null +++ b/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Controller/MyController.php @@ -0,0 +1,18 @@ +id; + } + + public function getEmail() + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + public function getUsername(): string + { + return (string) $this->email; + } + + 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; + } + + 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/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Repository/UserRepository.php b/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Repository/UserRepository.php new file mode 100644 index 000000000..8f695cbe0 --- /dev/null +++ b/tests/fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest/src/Repository/UserRepository.php @@ -0,0 +1,21 @@ +request('GET', '/register'); + + $form = $crawler->selectButton('Register')->form(); + $form['registration_form[email]'] = 'jr@rushlow.dev'; + $form['registration_form[plainPassword]'] = 'noAuth'; + $form['registration_form[agreeTerms]'] = true; + + $client->submit($form); + + $messages = $this->getMailerMessages(); + self::assertCount(1, $messages); + + /** @var EntityManager $em */ + $em = self::$kernel->getContainer() + ->get('doctrine') + ->getManager() + ; + + $query = $em->createQuery('SELECT u FROM App\\Entity\\User u WHERE u.email = \'jr@rushlow.dev\''); + + self::assertFalse(($query->getSingleResult())->isVerified()); + + $context = $messages[0]->getContext(); + + $client->request('GET', $context['signedUrl']); + + self::assertTrue(($query->getSingleResult())->isVerified()); + } +}