Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 67 additions & 23 deletions src/Maker/MakeRegistrationForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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'));

Expand Down Expand Up @@ -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\\'
Expand All @@ -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(
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Resources/config/makers.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.renderer.form_type_renderer" />
<argument type="service" id="router" />
<argument type="service" id="maker.doctrine_helper" />
<tag name="maker.command" />
</service>

Expand Down
27 changes: 25 additions & 2 deletions src/Resources/skeleton/registration/RegistrationController.tpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
use <?= $authenticator_full_class_name; ?>;
<?php endif; ?>
<?php if ($will_verify_email): ?>
<?php if ($verify_email_anonymously): ?>
use <?= $repository_full_class_name; ?>;
<?php endif; ?>
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
<?php endif; ?>
use Symfony\Bundle\FrameworkBundle\Controller\<?= $parent_class_name; ?>;
Expand Down Expand Up @@ -102,13 +105,33 @@ public function register(Request $request, UserPasswordEncoderInterface $passwor
* @Route("/verify/email", name="app_verify_email")
*/
<?php } ?>
public function verifyUserEmail(Request $request): Response
public function verifyUserEmail(Request $request<?= $verify_email_anonymously ? sprintf(', %s %s', $repository_class_name, $repository_var) : null ?>): Response
{
<?php if (!$verify_email_anonymously): ?>
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
<?php else: ?>
$id = $request->get('id');

if (null === $id) {
return $this->redirectToRoute('app_register');
}
<?php if ('$manager' === $repository_var): ?>

$repository = $manager->getRepository(<?= $user_class_name ?>::class);
$user = $repository->find($id);
<?php else: ?>

<?= $repository_var; ?>->find($id);
<?php endif; ?>

if (null === $user) {
return $this->redirectToRoute('app_register');
}
<?php endif; ?>

// validate email confirmation link, sets User::isVerified=true and persists
try {
$this->emailVerifier->handleEmailConfirmation($request, $this->getUser());
$this->emailVerifier->handleEmailConfirmation($request, <?= $verify_email_anonymously ? '$user' : '$this->getUser()' ?>);
} catch (VerifyEmailExceptionInterface $exception) {
$this->addFlash('verify_email_error', $exception->getReason());

Expand Down
5 changes: 5 additions & 0 deletions src/Resources/skeleton/verifyEmail/EmailVerifier.tpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ public function sendEmailConfirmation(string $verifyEmailRouteName, UserInterfac
$signatureComponents = $this->verifyEmailHelper->generateSignature(
$verifyEmailRouteName,
$user-><?= $id_getter ?>(),
<?php if ($verify_email_anonymously): ?>
$user-><?= $email_getter ?>(),
['id' => $user->getId()]
<?php else: ?>
$user-><?= $email_getter ?>()
<?php endif; ?>
);

$context = $email->getContext();
Expand Down
27 changes: 25 additions & 2 deletions tests/Maker/MakeRegistrationFormTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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'),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
framework:
mailer:
dsn: 'null://null'
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class MyController extends AbstractController
{
/**
* @Route("/", name="main")
*/
public function index(): Response
{
return new Response();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
* @ORM\Entity()
*/
class User implements UserInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;

/**
* @ORM\Column(type="string", length=180, unique=true)
*/
private $email;

/**
* @ORM\Column(type="array")
*/
private $roles = [];

/**
* @var string The hashed password
* @ORM\Column(type="string")
*/
private $password;

public function getId()
{
return $this->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()
{
}
}
Loading