Skip to content

Commit

Permalink
feature #776 [make:registration] allow email verification without aut…
Browse files Browse the repository at this point in the history
…hentication (jrushlow)

This PR was squashed before being merged into the 1.0-dev branch.

Discussion
----------

[make:registration] allow email verification without authentication

By passing the user id as an extra query param to `VerifyEmailHelper::generateSignature()` - users are able to verify their email address without being authenticated.

As a precautionary note, answering `no` to `Do you want to require the user to be authenticated to verify their email?` will allow anyone with the link generated by `VerifyEmailHelper` to validated that users email address. It should also be advised that answering `no` could possibly leak personally identifiable information in log files if the user `id` is changed to say, a users email address.

Commits
-------

ebdb227 [make:registration] allow email verification without authentication
  • Loading branch information
weaverryan committed Jan 8, 2021
2 parents 0f1d3ed + ebdb227 commit a47408f
Show file tree
Hide file tree
Showing 11 changed files with 319 additions and 27 deletions.
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
'[email protected]', // 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
'[email protected]', // 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
'[email protected]', // 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

0 comments on commit a47408f

Please sign in to comment.