* * 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 Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\Column; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; 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; use Symfony\Bundle\MakerBundle\InputConfiguration; use Symfony\Bundle\MakerBundle\Maker\Common\CanGenerateTestsTrait; use Symfony\Bundle\MakerBundle\Renderer\FormTypeRenderer; use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper; use Symfony\Bundle\MakerBundle\Security\Model\Authenticator; use Symfony\Bundle\MakerBundle\Security\Model\AuthenticatorType; use Symfony\Bundle\MakerBundle\Str; use Symfony\Bundle\MakerBundle\Util\ClassDetails; use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator; use Symfony\Bundle\MakerBundle\Util\CliOutputHelper; use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator; use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator; use Symfony\Bundle\MakerBundle\Validator; use Symfony\Bundle\SecurityBundle\Security; 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\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Validation; use Symfony\Contracts\Translation\TranslatorInterface; use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface; use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelper; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; /** * @author Ryan Weaver * @author Jesse Rushlow * * @internal */ final class MakeRegistrationForm extends AbstractMaker { use CanGenerateTestsTrait; private string $userClass; private string $usernameField; private string $passwordField; private bool $willVerifyEmail = false; private bool $verifyEmailAnonymously = false; private string $idGetter; private string $emailGetter; private string $fromEmailAddress; private string $fromEmailName; private ?Authenticator $autoLoginAuthenticator = null; private string $redirectRouteName; private bool $addUniqueEntityConstraint = false; public function __construct( private FileManager $fileManager, private FormTypeRenderer $formTypeRenderer, private DoctrineHelper $doctrineHelper, private ?RouterInterface $router = null, ) { } public static function getCommandName(): string { return 'make:registration-form'; } public static function getCommandDescription(): string { return 'Create a new registration form system'; } public function configureCommand(Command $command, InputConfiguration $inputConfig): void { $command ->setHelp($this->getHelpFileContents('MakeRegistrationForm.txt')) ; $this->configureCommandWithTestsOption($command); } public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void { $interactiveSecurityHelper = new InteractiveSecurityHelper(); if (null === $this->router) { throw new RuntimeCommandException('Router have been explicitly disabled in your configuration. This command needs to use the router.'); } if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) { throw new RuntimeCommandException('The file "config/packages/security.yaml" does not exist. PHP & XML configuration formats are currently not supported.'); } $manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path)); $securityData = $manipulator->getData(); $providersData = $securityData['security']['providers'] ?? []; $this->userClass = $interactiveSecurityHelper->guessUserClass( $io, $providersData, 'Enter the User class that you want to create during registration (e.g. App\\Entity\\User)' ); $io->text(\sprintf('Creating a registration form for %s', $this->userClass)); $this->usernameField = $interactiveSecurityHelper->guessUserNameField($io, $this->userClass, $providersData); $this->passwordField = $interactiveSecurityHelper->guessPasswordField($io, $this->userClass); // see if it makes sense to add the UniqueEntity constraint $userClassDetails = new ClassDetails($this->userClass); if (!$userClassDetails->hasAttribute(UniqueEntity::class)) { $this->addUniqueEntityConstraint = (bool) $io->confirm(\sprintf('Do you want to add a #[UniqueEntity] validation attribute to your %s class to make sure duplicate accounts aren\'t created?', Str::getShortClassName($this->userClass))); } $this->willVerifyEmail = (bool) $io->confirm('Do you want to send an email to verify the user\'s email address after registration?'); if ($this->willVerifyEmail) { $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(); $this->verifyEmailAnonymously = (bool) $io->confirm('Would you like to include the user id in the verification link to allow anonymous email verification?', false); $this->idGetter = $interactiveSecurityHelper->guessIdGetter($io, $this->userClass); $this->emailGetter = $interactiveSecurityHelper->guessEmailGetter($io, $this->userClass, 'email'); $this->fromEmailAddress = $io->ask( 'What email address will be used to send registration confirmations? (e.g. mailer@your-domain.com)', null, Validator::validateEmailAddress(...) ); $this->fromEmailName = $io->ask( 'What "name" should be associated with that email address? (e.g. Acme Mail Bot)', null, Validator::notBlank(...) ); } if ($io->confirm('Do you want to automatically authenticate the user after registration?')) { $this->interactAuthenticatorQuestions( $io, $interactiveSecurityHelper, $securityData ); } if (!$this->autoLoginAuthenticator) { $routeNames = array_keys($this->router->getRouteCollection()->all()); $this->redirectRouteName = $io->choice('What route should the user be redirected to after registration?', $routeNames); } $this->interactSetGenerateTests($input, $io); } /** @param array $securityData */ private function interactAuthenticatorQuestions(ConsoleStyle $io, InteractiveSecurityHelper $interactiveSecurityHelper, array $securityData): void { // get list of authenticators $authenticators = $interactiveSecurityHelper->getAuthenticatorsFromConfig($securityData['security']['firewalls'] ?? []); if (empty($authenticators)) { $io->note('No authenticators found - so your user won\'t be automatically authenticated after registering.'); return; } $this->autoLoginAuthenticator = 1 === \count($authenticators) ? $authenticators[0] : $io->choice( 'Which authenticator should be used to login the user?', $authenticators ); } public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void { $userClassNameDetails = $generator->createClassNameDetails( '\\'.$this->userClass, 'Entity\\' ); $userDoctrineDetails = $this->doctrineHelper->createDoctrineDetails($userClassNameDetails->getFullName()); $userRepoVars = [ 'repository_full_class_name' => EntityManagerInterface::class, '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\\' ); $verifyEmailVars = ['will_verify_email' => $this->willVerifyEmail]; if ($this->willVerifyEmail) { $verifyEmailVars = [ 'will_verify_email' => $this->willVerifyEmail, 'email_verifier_class_details' => $verifyEmailServiceClassNameDetails, 'verify_email_anonymously' => $this->verifyEmailAnonymously, 'from_email' => $this->fromEmailAddress, 'from_email_name' => addslashes($this->fromEmailName), 'email_getter' => $this->emailGetter, ]; $useStatements = new UseStatementGenerator([ EntityManagerInterface::class, TemplatedEmail::class, Request::class, MailerInterface::class, UserInterface::class, VerifyEmailExceptionInterface::class, VerifyEmailHelperInterface::class, $userClassNameDetails->getFullName(), ]); $generator->generateClass( $verifyEmailServiceClassNameDetails->getFullName(), 'verifyEmail/EmailVerifier.tpl.php', array_merge([ 'use_statements' => $useStatements, 'id_getter' => $this->idGetter, 'email_getter' => $this->emailGetter, 'verify_email_anonymously' => $this->verifyEmailAnonymously, 'user_class_name' => $userClassNameDetails->getShortName(), ], $userRepoVars ) ); $generator->generateTemplate( 'registration/confirmation_email.html.twig', 'registration/twig_email.tpl.php' ); } // 1) Generate the form class $usernameField = $this->usernameField; $formClassDetails = $this->generateFormClass( $userClassNameDetails, $generator, $usernameField ); // 2) Generate the controller $controllerClassNameDetails = $generator->createClassNameDetails( 'RegistrationController', 'Controller\\' ); $useStatements = new UseStatementGenerator([ AbstractController::class, $formClassDetails->getFullName(), $userClassNameDetails->getFullName(), Request::class, Response::class, Route::class, UserPasswordHasherInterface::class, EntityManagerInterface::class, ]); if ($this->willVerifyEmail) { $useStatements->addUseStatement([ $verifyEmailServiceClassNameDetails->getFullName(), TemplatedEmail::class, Address::class, VerifyEmailExceptionInterface::class, ]); if ($this->verifyEmailAnonymously) { $useStatements->addUseStatement($userRepoVars['repository_full_class_name']); } } $autoLoginVars = [ 'login_after_registration' => null !== $this->autoLoginAuthenticator, ]; if (null !== $this->autoLoginAuthenticator) { $useStatements->addUseStatement([ Security::class, ]); $autoLoginVars['firewall'] = $this->autoLoginAuthenticator->firewallName; $autoLoginVars['authenticator'] = \sprintf('\'%s\'', $this->autoLoginAuthenticator->type->value); if (AuthenticatorType::CUSTOM === $this->autoLoginAuthenticator->type) { $useStatements->addUseStatement($this->autoLoginAuthenticator->authenticatorClass); $autoLoginVars['authenticator'] = \sprintf('%s::class', Str::getShortClassName($this->autoLoginAuthenticator->authenticatorClass)); } } if ($isTranslatorAvailable = class_exists(Translator::class)) { $useStatements->addUseStatement(TranslatorInterface::class); } $generator->generateController( $controllerClassNameDetails->getFullName(), 'registration/RegistrationController.tpl.php', array_merge([ 'use_statements' => $useStatements, 'route_path' => '/register', 'route_name' => 'app_register', 'form_class_name' => $formClassDetails->getShortName(), 'user_class_name' => $userClassNameDetails->getShortName(), 'password_field' => $this->passwordField, 'redirect_route_name' => $this->redirectRouteName ?? null, 'translator_available' => $isTranslatorAvailable, ], $userRepoVars, $autoLoginVars, $verifyEmailVars, ) ); // 3) Generate the template $generator->generateTemplate( 'registration/register.html.twig', 'registration/twig_template.tpl.php', [ 'username_field' => $usernameField, 'will_verify_email' => $this->willVerifyEmail, ] ); // 4) Update the User class if necessary if ($this->addUniqueEntityConstraint) { $classDetails = new ClassDetails($this->userClass); $userManipulator = new ClassSourceManipulator( sourceCode: file_get_contents($classDetails->getPath()) ); $userManipulator->setIo($io); if ($this->doctrineHelper->isDoctrineSupportingAttributes()) { $userManipulator->addAttributeToClass( UniqueEntity::class, ['fields' => [$usernameField], 'message' => \sprintf('There is already an account with this %s', $usernameField)] ); } $this->fileManager->dumpFile($classDetails->getPath(), $userManipulator->getSourceCode()); } if ($this->willVerifyEmail) { $classDetails = new ClassDetails($this->userClass); $userManipulator = new ClassSourceManipulator( sourceCode: file_get_contents($classDetails->getPath()), overwrite: false, ); $userManipulator->setIo($io); $userManipulator->addProperty( name: 'isVerified', defaultValue: false, attributes: [$userManipulator->buildAttributeNode(attributeClass: Column::class, options: [], attributePrefix: 'ORM')], propertyType: 'bool' ); $userManipulator->addAccessorMethod('isVerified', 'isVerified', 'bool', false); $userManipulator->addSetter('isVerified', 'bool', false); $this->fileManager->dumpFile($classDetails->getPath(), $userManipulator->getSourceCode()); } // Generate PHPUnit Tests if ($this->shouldGenerateTests()) { $testClassDetails = $generator->createClassNameDetails( 'RegistrationControllerTest', 'Test\\' ); $useStatements = new UseStatementGenerator([ EntityManager::class, KernelBrowser::class, TemplatedEmail::class, WebTestCase::class, $userRepoVars['repository_full_class_name'], ]); $generator->generateFile( targetPath: \sprintf('tests/%s.php', $testClassDetails->getShortName()), templateName: $this->willVerifyEmail ? 'registration/Test.WithVerify.tpl.php' : 'registration/Test.WithoutVerify.tpl.php', variables: array_merge([ 'use_statements' => $useStatements, 'from_email' => $this->fromEmailAddress ?? null, ], $userRepoVars) ); if (!class_exists(WebTestCase::class)) { $io->caution('You\'ll need to install the `symfony/test-pack` to execute the tests for your new controller.'); } } $generator->writeChanges(); $this->writeSuccessMessage($io); $this->successMessage($io, $this->willVerifyEmail, $userClassNameDetails->getShortName()); } private function successMessage(ConsoleStyle $io, bool $emailVerification, string $userClass): void { $closing[] = 'Next:'; if (!$emailVerification) { $closing[] = 'Make any changes you need to the form, controller & template.'; } else { $index = 1; if ($missingPackagesMessage = $this->getMissingComponentsComposerMessage()) { $closing[] = '1) Install some missing packages:'; $closing[] = \sprintf(' %s', $missingPackagesMessage); ++$index; } $closing[] = \sprintf('%d) In RegistrationController::verifyUserEmail():', $index++); $closing[] = ' * Customize the last redirectToRoute() after a successful email verification.'; $closing[] = ' * Make sure you\'re rendering success flash messages or change the $this->addFlash() line.'; $closing[] = \sprintf('%d) Review and customize the form, controller, and templates as needed.', $index++); $closing[] = \sprintf('%d) Run "%s make:migration" to generate a migration for the newly added %s::isVerified property.', $index++, CliOutputHelper::getCommandPrefix(), $userClass); } $io->text($closing); $io->newLine(); $io->text('Then open your browser, go to "/register" and enjoy your new form!'); $io->newLine(); } private function checkComponentsExist(ConsoleStyle $io): void { $message = $this->getMissingComponentsComposerMessage(); if ($message) { $io->warning([ 'We\'re missing some important components. Don\'t forget to install these after you\'re finished.', $message, ]); } } private function getMissingComponentsComposerMessage(): ?string { $missing = false; $composerMessage = 'composer require'; // verify-email-bundle 1.17.0 includes the new validateEmailConfirmationFromRequest method. // we need to check that if the bundle is installed, it is version 1.17.0 or greater if (class_exists(SymfonyCastsVerifyEmailBundle::class)) { $reflectedComponents = new \ReflectionClass(VerifyEmailHelper::class); if (!$reflectedComponents->hasMethod('validateEmailConfirmationFromRequest')) { throw new RuntimeCommandException('Please upgrade symfonycasts/verify-email-bundle to version 1.17.0 or greater.'); } } else { $missing = true; $composerMessage = \sprintf('%s symfonycasts/verify-email-bundle', $composerMessage); } if (!interface_exists(MailerInterface::class)) { $missing = true; $composerMessage = \sprintf('%s symfony/mailer', $composerMessage); } if (!$missing) { return null; } return $composerMessage; } public function configureDependencies(DependencyBuilder $dependencies): void { $dependencies->addClassDependency( AbstractType::class, 'form' ); $dependencies->addClassDependency( Validation::class, 'validator' ); $dependencies->addClassDependency( TwigBundle::class, 'twig-bundle' ); $dependencies->addClassDependency( DoctrineBundle::class, 'orm' ); $dependencies->addClassDependency( SecurityBundle::class, 'security' ); } private function generateFormClass(ClassNameDetails $userClassDetails, Generator $generator, string $usernameField): ClassNameDetails { $formClassDetails = $generator->createClassNameDetails( 'RegistrationFormType', 'Form\\' ); $formFields = [ $usernameField => null, 'agreeTerms' => [ 'type' => CheckboxType::class, 'options_code' => << false, 'constraints' => [ new IsTrue([ 'message' => 'You should agree to our terms.', ]), ], EOF ], 'plainPassword' => [ 'type' => PasswordType::class, 'options_code' => << false, 'attr' => ['autocomplete' => 'new-password'], '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, ]), ], EOF ], ]; $this->formTypeRenderer->render( $formClassDetails, $formFields, $userClassDetails, [ 'Symfony\Component\Validator\Constraints\IsTrue', 'Symfony\Component\Validator\Constraints\Length', 'Symfony\Component\Validator\Constraints\NotBlank', ] ); return $formClassDetails; } }