Skip to content

Commit a47408f

Browse files
committed
feature #776 [make:registration] allow email verification without authentication (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
2 parents 0f1d3ed + ebdb227 commit a47408f

File tree

11 files changed

+319
-27
lines changed

11 files changed

+319
-27
lines changed

src/Maker/MakeRegistrationForm.php

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
1717
use Symfony\Bundle\MakerBundle\ConsoleStyle;
1818
use Symfony\Bundle\MakerBundle\DependencyBuilder;
19+
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
1920
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
2021
use Symfony\Bundle\MakerBundle\FileManager;
2122
use Symfony\Bundle\MakerBundle\Generator;
@@ -55,11 +56,14 @@ final class MakeRegistrationForm extends AbstractMaker
5556

5657
private $router;
5758

58-
public function __construct(FileManager $fileManager, FormTypeRenderer $formTypeRenderer, RouterInterface $router)
59+
private $doctrineHelper;
60+
61+
public function __construct(FileManager $fileManager, FormTypeRenderer $formTypeRenderer, RouterInterface $router, DoctrineHelper $doctrineHelper)
5962
{
6063
$this->fileManager = $fileManager;
6164
$this->formTypeRenderer = $formTypeRenderer;
6265
$this->router = $router;
66+
$this->doctrineHelper = $doctrineHelper;
6367
}
6468

6569
public static function getCommandName(): string
@@ -83,6 +87,7 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma
8387
->addArgument('username-field')
8488
->addArgument('password-field')
8589
->addArgument('will-verify-email')
90+
->addArgument('verify-email-anonymously')
8691
->addArgument('id-getter')
8792
->addArgument('email-getter')
8893
->addArgument('from-email-address')
@@ -138,9 +143,22 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma
138143

139144
$input->setArgument('will-verify-email', $willVerify);
140145

146+
// This must be preset to true to avoid code being generated if $willVerify === false
147+
$input->setArgument('verify-email-anonymously', false);
148+
141149
if ($willVerify) {
142150
$this->checkComponentsExist($io);
143151

152+
$emailText[] = 'By default, users are required to be authenticated when they click the verification link that is emailed to them.';
153+
$emailText[] = 'This prevents the user from registering on their laptop, then clicking the link on their phone, without';
154+
$emailText[] = 'having to log in. To allow multi device email verification, we can embed a user id in the verification link.';
155+
$io->text($emailText);
156+
$io->newLine();
157+
$input->setArgument(
158+
'verify-email-anonymously',
159+
$io->confirm('Would you like to include the user id in the verification link to allow anonymous email verification?', false)
160+
);
161+
144162
$input->setArgument('id-getter', $interactiveSecurityHelper->guessIdGetter($io, $userClass));
145163
$input->setArgument('email-getter', $interactiveSecurityHelper->guessEmailGetter($io, $userClass, 'email'));
146164

@@ -219,6 +237,26 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
219237
'Entity\\'
220238
);
221239

240+
$userDoctrineDetails = $this->doctrineHelper->createDoctrineDetails($userClassNameDetails->getFullName());
241+
242+
$userRepoVars = [
243+
'repository_full_class_name' => 'Doctrine\ORM\EntityManagerInterface',
244+
'repository_class_name' => 'EntityManagerInterface',
245+
'repository_var' => '$manager',
246+
];
247+
248+
$userRepository = $userDoctrineDetails->getRepositoryClass();
249+
250+
if (null !== $userRepository) {
251+
$userRepoClassDetails = $generator->createClassNameDetails('\\'.$userRepository, 'Repository\\', 'Repository');
252+
253+
$userRepoVars = [
254+
'repository_full_class_name' => $userRepoClassDetails->getFullName(),
255+
'repository_class_name' => $userRepoClassDetails->getShortName(),
256+
'repository_var' => sprintf('$%s', lcfirst($userRepoClassDetails->getShortName())),
257+
];
258+
}
259+
222260
$verifyEmailServiceClassNameDetails = $generator->createClassNameDetails(
223261
'EmailVerifier',
224262
'Security\\'
@@ -228,10 +266,13 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
228266
$generator->generateClass(
229267
$verifyEmailServiceClassNameDetails->getFullName(),
230268
'verifyEmail/EmailVerifier.tpl.php',
231-
[
232-
'id_getter' => $input->getArgument('id-getter'),
233-
'email_getter' => $input->getArgument('email-getter'),
234-
]
269+
array_merge([
270+
'id_getter' => $input->getArgument('id-getter'),
271+
'email_getter' => $input->getArgument('email-getter'),
272+
'verify_email_anonymously' => $input->getArgument('verify-email-anonymously'),
273+
],
274+
$userRepoVars
275+
)
235276
);
236277

237278
$generator->generateTemplate(
@@ -258,24 +299,27 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
258299
$generator->generateController(
259300
$controllerClassNameDetails->getFullName(),
260301
'registration/RegistrationController.tpl.php',
261-
[
262-
'route_path' => '/register',
263-
'route_name' => 'app_register',
264-
'form_class_name' => $formClassDetails->getShortName(),
265-
'form_full_class_name' => $formClassDetails->getFullName(),
266-
'user_class_name' => $userClassNameDetails->getShortName(),
267-
'user_full_class_name' => $userClassNameDetails->getFullName(),
268-
'password_field' => $input->getArgument('password-field'),
269-
'will_verify_email' => $input->getArgument('will-verify-email'),
270-
'verify_email_security_service' => $verifyEmailServiceClassNameDetails->getFullName(),
271-
'from_email' => $input->getArgument('from-email-address'),
272-
'from_email_name' => $input->getArgument('from-email-name'),
273-
'email_getter' => $input->getArgument('email-getter'),
274-
'authenticator_class_name' => $authenticatorClassName ? Str::getShortClassName($authenticatorClassName) : null,
275-
'authenticator_full_class_name' => $authenticatorClassName,
276-
'firewall_name' => $input->getOption('firewall-name'),
277-
'redirect_route_name' => $input->getOption('redirect-route-name'),
278-
]
302+
array_merge([
303+
'route_path' => '/register',
304+
'route_name' => 'app_register',
305+
'form_class_name' => $formClassDetails->getShortName(),
306+
'form_full_class_name' => $formClassDetails->getFullName(),
307+
'user_class_name' => $userClassNameDetails->getShortName(),
308+
'user_full_class_name' => $userClassNameDetails->getFullName(),
309+
'password_field' => $input->getArgument('password-field'),
310+
'will_verify_email' => $input->getArgument('will-verify-email'),
311+
'verify_email_anonymously' => $input->getArgument('verify-email-anonymously'),
312+
'verify_email_security_service' => $verifyEmailServiceClassNameDetails->getFullName(),
313+
'from_email' => $input->getArgument('from-email-address'),
314+
'from_email_name' => $input->getArgument('from-email-name'),
315+
'email_getter' => $input->getArgument('email-getter'),
316+
'authenticator_class_name' => $authenticatorClassName ? Str::getShortClassName($authenticatorClassName) : null,
317+
'authenticator_full_class_name' => $authenticatorClassName,
318+
'firewall_name' => $input->getOption('firewall-name'),
319+
'redirect_route_name' => $input->getOption('redirect-route-name'),
320+
],
321+
$userRepoVars
322+
)
279323
);
280324

281325
// 3) Generate the template

src/Resources/config/makers.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
<argument type="service" id="maker.file_manager" />
7272
<argument type="service" id="maker.renderer.form_type_renderer" />
7373
<argument type="service" id="router" />
74+
<argument type="service" id="maker.doctrine_helper" />
7475
<tag name="maker.command" />
7576
</service>
7677

src/Resources/skeleton/registration/RegistrationController.tpl.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
use <?= $authenticator_full_class_name; ?>;
1212
<?php endif; ?>
1313
<?php if ($will_verify_email): ?>
14+
<?php if ($verify_email_anonymously): ?>
15+
use <?= $repository_full_class_name; ?>;
16+
<?php endif; ?>
1417
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
1518
<?php endif; ?>
1619
use Symfony\Bundle\FrameworkBundle\Controller\<?= $parent_class_name; ?>;
@@ -102,13 +105,33 @@ public function register(Request $request, UserPasswordEncoderInterface $passwor
102105
* @Route("/verify/email", name="app_verify_email")
103106
*/
104107
<?php } ?>
105-
public function verifyUserEmail(Request $request): Response
108+
public function verifyUserEmail(Request $request<?= $verify_email_anonymously ? sprintf(', %s %s', $repository_class_name, $repository_var) : null ?>): Response
106109
{
110+
<?php if (!$verify_email_anonymously): ?>
107111
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
112+
<?php else: ?>
113+
$id = $request->get('id');
114+
115+
if (null === $id) {
116+
return $this->redirectToRoute('app_register');
117+
}
118+
<?php if ('$manager' === $repository_var): ?>
119+
120+
$repository = $manager->getRepository(<?= $user_class_name ?>::class);
121+
$user = $repository->find($id);
122+
<?php else: ?>
123+
124+
<?= $repository_var; ?>->find($id);
125+
<?php endif; ?>
126+
127+
if (null === $user) {
128+
return $this->redirectToRoute('app_register');
129+
}
130+
<?php endif; ?>
108131

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

src/Resources/skeleton/verifyEmail/EmailVerifier.tpl.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ public function sendEmailConfirmation(string $verifyEmailRouteName, UserInterfac
2828
$signatureComponents = $this->verifyEmailHelper->generateSignature(
2929
$verifyEmailRouteName,
3030
$user-><?= $id_getter ?>(),
31+
<?php if ($verify_email_anonymously): ?>
32+
$user-><?= $email_getter ?>(),
33+
['id' => $user->getId()]
34+
<?php else: ?>
3135
$user-><?= $email_getter ?>()
36+
<?php endif; ?>
3237
);
3338

3439
$context = $email->getContext();

tests/Maker/MakeRegistrationFormTest.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ public function getTestDetails()
7979
$this->getMakerInstance(MakeRegistrationForm::class),
8080
[
8181
'n', // add UniqueEntity
82-
'y', // no verify user
82+
'y', // verify user
83+
'y', // require authentication to verify user email
8384
'[email protected]', // from email address
8485
'SymfonyCasts', // From Name
8586
'n', // no authenticate after
@@ -110,7 +111,8 @@ function (string $output, string $directory) {
110111
$this->getMakerInstance(MakeRegistrationForm::class),
111112
[
112113
'n', // add UniqueEntity
113-
'y', // no verify user
114+
'y', // verify user
115+
'n', // require authentication to verify user email
114116
'[email protected]', // from email address
115117
'SymfonyCasts', // From Name
116118
'', // yes authenticate after
@@ -125,5 +127,26 @@ function (string $output, string $directory) {
125127
->addExtraDependencies('symfony/web-profiler-bundle')
126128
->addExtraDependencies('mailer'),
127129
];
130+
131+
yield 'verify_email_no_auth_functional_test' => [MakerTestDetails::createTest(
132+
$this->getMakerInstance(MakeRegistrationForm::class),
133+
[
134+
'n', // add UniqueEntity
135+
'y', // verify user's email
136+
'y', // require authentication to verify user email
137+
'[email protected]', // from email address
138+
'SymfonyCasts', // From Name
139+
'', // yes authenticate after
140+
'main', // redirect to route after registration
141+
])
142+
->setRequiredPhpVersion(70200)
143+
->setFixtureFilesPath(__DIR__.'/../fixtures/MakeRegistrationFormVerifyEmailNoAuthFunctionalTest')
144+
->addExtraDependencies('symfonycasts/verify-email-bundle')
145+
->configureDatabase()
146+
->updateSchemaAfterCommand()
147+
// needed for internal functional test
148+
->addExtraDependencies('symfony/web-profiler-bundle')
149+
->addExtraDependencies('mailer'),
150+
];
128151
}
129152
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
framework:
2+
mailer:
3+
dsn: 'null://null'
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
security:
2+
encoders:
3+
App\Entity\User: bcrypt
4+
5+
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
6+
providers:
7+
app_user_provider:
8+
entity:
9+
class: App\Entity\User
10+
property: email
11+
12+
firewalls:
13+
dev:
14+
pattern: ^/(_(profiler|wdt)|css|images|js)/
15+
security: false
16+
main:
17+
anonymous: true
18+
# guard:
19+
# authenticators:
20+
# - App\Security\StubAuthenticator
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace App\Controller;
4+
5+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
6+
use Symfony\Component\HttpFoundation\Response;
7+
use Symfony\Component\Routing\Annotation\Route;
8+
9+
class MyController extends AbstractController
10+
{
11+
/**
12+
* @Route("/", name="main")
13+
*/
14+
public function index(): Response
15+
{
16+
return new Response();
17+
}
18+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace App\Entity;
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
use Symfony\Component\Security\Core\User\UserInterface;
7+
8+
/**
9+
* @ORM\Entity()
10+
*/
11+
class User implements UserInterface
12+
{
13+
/**
14+
* @ORM\Id
15+
* @ORM\GeneratedValue
16+
* @ORM\Column(type="integer")
17+
*/
18+
private $id;
19+
20+
/**
21+
* @ORM\Column(type="string", length=180, unique=true)
22+
*/
23+
private $email;
24+
25+
/**
26+
* @ORM\Column(type="array")
27+
*/
28+
private $roles = [];
29+
30+
/**
31+
* @var string The hashed password
32+
* @ORM\Column(type="string")
33+
*/
34+
private $password;
35+
36+
public function getId()
37+
{
38+
return $this->id;
39+
}
40+
41+
public function getEmail()
42+
{
43+
return $this->email;
44+
}
45+
46+
public function setEmail(string $email): self
47+
{
48+
$this->email = $email;
49+
50+
return $this;
51+
}
52+
53+
public function getUsername(): string
54+
{
55+
return (string) $this->email;
56+
}
57+
58+
public function getRoles(): array
59+
{
60+
$roles = $this->roles;
61+
// guarantee every user at least has ROLE_USER
62+
$roles[] = 'ROLE_USER';
63+
64+
return array_unique($roles);
65+
}
66+
67+
public function setRoles(array $roles): self
68+
{
69+
$this->roles = $roles;
70+
71+
return $this;
72+
}
73+
74+
public function getPassword(): string
75+
{
76+
return (string) $this->password;
77+
}
78+
79+
public function setPassword(string $password): self
80+
{
81+
$this->password = $password;
82+
83+
return $this;
84+
}
85+
86+
public function getSalt()
87+
{
88+
}
89+
90+
public function eraseCredentials()
91+
{
92+
}
93+
}

0 commit comments

Comments
 (0)