Skip to content

Commit ad6c775

Browse files
committed
Allow editing emails after registration
This will hopefully let people who made a typo realize it and fix it themselves
1 parent e098c09 commit ad6c775

File tree

4 files changed

+449
-3
lines changed

4 files changed

+449
-3
lines changed

src/Controller/RegistrationController.php

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
use App\Entity\User;
1616
use App\Entity\UserRepository;
1717
use App\Form\RegistrationFormType;
18+
use App\Form\UpdateEmailFormType;
1819
use App\Security\BruteForceLoginFormAuthenticator;
1920
use App\Security\EmailVerifier;
2021
use App\Security\UserChecker;
22+
use Psr\Clock\ClockInterface;
2123
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
2224
use Symfony\Component\HttpFoundation\Request;
2325
use Symfony\Component\HttpFoundation\Response;
@@ -30,7 +32,7 @@
3032

3133
class RegistrationController extends Controller
3234
{
33-
public function __construct(private EmailVerifier $emailVerifier)
35+
public function __construct(private EmailVerifier $emailVerifier, private string $internalSecret, private ClockInterface $clock)
3436
{
3537
}
3638

@@ -68,16 +70,83 @@ public function register(Request $request, UserPasswordHasherInterface $password
6870
->htmlTemplate('registration/confirmation_email.html.twig')
6971
->textTemplate('registration/confirmation_email.txt.twig')
7072
);
71-
$this->addFlash('success', 'Your account has been created. Please check your email inbox to confirm the account.');
7273

73-
return $this->redirectToRoute('home');
74+
// Redirect to confirmation page with signed token
75+
$token = $this->generateRegistrationToken($user);
76+
77+
return $this->redirectToRoute('register_check_email', ['token' => $token]);
7478
}
7579

7680
return $this->render('registration/register.html.twig', [
7781
'registrationForm' => $form,
7882
]);
7983
}
8084

85+
#[Route(path: '/register/check-email/{token}', name: 'register_check_email')]
86+
public function checkEmailConfirmation(string $token, UserRepository $userRepository): Response
87+
{
88+
$result = $this->validateRegistrationToken($token, $userRepository);
89+
90+
if ($result === null) {
91+
$this->addFlash('error', 'This link is invalid or has expired. Please register again.');
92+
return $this->redirectToRoute('register');
93+
}
94+
95+
$form = $this->createForm(UpdateEmailFormType::class, $result['user']);
96+
97+
return $this->render('registration/check_email.html.twig', [
98+
'email' => $result['email'],
99+
'token' => $token,
100+
'form' => $form,
101+
]);
102+
}
103+
104+
#[Route(path: '/register/resend/{token}', name: 'register_resend', methods: ['POST'])]
105+
public function resendConfirmation(string $token, Request $request, UserRepository $userRepository, string $mailFromEmail, string $mailFromName): Response
106+
{
107+
$result = $this->validateRegistrationToken($token, $userRepository);
108+
109+
if ($result === null) {
110+
$this->addFlash('error', 'This link is invalid or has expired. Please register again.');
111+
return $this->redirectToRoute('register');
112+
}
113+
114+
$user = $result['user'];
115+
$form = $this->createForm(UpdateEmailFormType::class, $user);
116+
$form->handleRequest($request);
117+
118+
if ($form->isSubmitted() && $form->isValid()) {
119+
// Persist email change if it was modified
120+
$this->getEM()->flush();
121+
122+
// Resend confirmation email
123+
$this->emailVerifier->sendEmailConfirmation(
124+
'register_confirm_email',
125+
$user,
126+
new TemplatedEmail()
127+
->from(new Address($mailFromEmail, $mailFromName))
128+
->to($user->getEmail())
129+
->subject('Please confirm your email')
130+
->htmlTemplate('registration/confirmation_email.html.twig')
131+
->textTemplate('registration/confirmation_email.txt.twig')
132+
);
133+
134+
// Generate new token with updated email
135+
$newToken = $this->generateRegistrationToken($user);
136+
137+
$this->addFlash('success', 'Confirmation email has been sent to ' . $user->getEmail());
138+
139+
return $this->redirectToRoute('register_check_email', ['token' => $newToken]);
140+
}
141+
142+
// If form is invalid, redisplay the page with errors
143+
return $this->render('registration/check_email.html.twig', [
144+
'email' => $user->getEmail(),
145+
'token' => $token,
146+
'form' => $form,
147+
]);
148+
}
149+
81150
/**
82151
* @param BruteForceLoginFormAuthenticator<User> $authenticator
83152
*/
@@ -119,4 +188,57 @@ public function confirmEmail(Request $request, UserRepository $userRepository, U
119188

120189
return $this->redirectToRoute('home');
121190
}
191+
192+
private function generateRegistrationToken(User $user): string
193+
{
194+
$timestamp = $this->clock->now()->getTimestamp();
195+
$data = $user->getId() . '|' . $user->getEmail() . '|' . $timestamp;
196+
$signature = hash_hmac('sha256', $data, $this->internalSecret);
197+
198+
return base64_encode($data . '|' . $signature);
199+
}
200+
201+
/**
202+
* @return array{user: User, email: string}|null
203+
*/
204+
private function validateRegistrationToken(string $token, UserRepository $userRepository): ?array
205+
{
206+
$decoded = base64_decode($token, true);
207+
if ($decoded === false) {
208+
return null;
209+
}
210+
211+
$parts = explode('|', $decoded);
212+
if (count($parts) !== 4) {
213+
return null;
214+
}
215+
216+
[$userId, $email, $timestamp, $providedSignature] = $parts;
217+
218+
// Check expiration (10 minutes = 600 seconds)
219+
$now = $this->clock->now()->getTimestamp();
220+
if ($now - (int) $timestamp > 600) {
221+
return null;
222+
}
223+
224+
// Verify signature
225+
$data = $userId . '|' . $email . '|' . $timestamp;
226+
$expectedSignature = hash_hmac('sha256', $data, $this->internalSecret);
227+
if (!hash_equals($expectedSignature, $providedSignature)) {
228+
return null;
229+
}
230+
231+
// Load user
232+
$user = $userRepository->find((int) $userId);
233+
if ($user === null) {
234+
return null;
235+
}
236+
237+
// Check if user is already enabled
238+
if ($user->isEnabled()) {
239+
return null;
240+
}
241+
242+
return ['user' => $user, 'email' => $email];
243+
}
122244
}

src/Form/UpdateEmailFormType.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Form;
14+
15+
use App\Entity\User;
16+
use Symfony\Component\Form\AbstractType;
17+
use Symfony\Component\Form\Extension\Core\Type\EmailType;
18+
use Symfony\Component\Form\FormBuilderInterface;
19+
use Symfony\Component\OptionsResolver\OptionsResolver;
20+
21+
/**
22+
* Used to update the email post-registration and before email has been confirmed, in case of typos
23+
*
24+
* @extends AbstractType<User>
25+
*/
26+
class UpdateEmailFormType extends AbstractType
27+
{
28+
public function buildForm(FormBuilderInterface $builder, array $options): void
29+
{
30+
$builder
31+
->add('email', EmailType::class, ['empty_data' => ''])
32+
;
33+
}
34+
35+
public function configureOptions(OptionsResolver $resolver): void
36+
{
37+
$resolver->setDefaults([
38+
'data_class' => User::class,
39+
'validation_groups' => ['Default', 'Registration'],
40+
]);
41+
}
42+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{% extends 'user/layout.html.twig' %}
2+
3+
{% block title %}Check your email - {{ parent() }}{% endblock %}
4+
5+
{% block user_content %}
6+
<div class="col-md-12">
7+
<h2 class="title">
8+
Check your email
9+
</h2>
10+
</div>
11+
12+
<div class="col-md-8">
13+
<div class="alert alert-success" role="alert">
14+
<strong>Success!</strong> Your account has been created. We've sent a confirmation email to verify your address.
15+
</div>
16+
17+
<p>
18+
We've sent a confirmation email to <strong>{{ email }}</strong>.
19+
Please check your inbox and click the link in the email to activate your account.
20+
</p>
21+
22+
<hr>
23+
24+
<h4>Made a typo?</h4>
25+
<p>If you notice an error in your email address, you can correct it below and resend the confirmation email.</p>
26+
27+
{{ form_start(form, {
28+
action: path('register_resend', {token: token}),
29+
attr: {class: "form-horizontal"}
30+
}) }}
31+
<div class="form-group">
32+
<label for="{{ form.email.vars.id }}" class="col-sm-3 control-label">Email address:</label>
33+
<div class="col-sm-9">
34+
{{ form_errors(form.email) }}
35+
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
36+
</div>
37+
</div>
38+
39+
<div class="form-group">
40+
<div class="col-sm-offset-3 col-sm-9">
41+
<button type="submit" class="btn btn-primary">
42+
<span class="glyphicon glyphicon-envelope"></span> Update &amp; Resend Confirmation Email
43+
</button>
44+
<br>
45+
<strong>Note:</strong> This link is valid for 10 minutes.
46+
</div>
47+
</div>
48+
{{ form_end(form) }}
49+
50+
<p class="text-muted">
51+
<small>
52+
Didn't receive the email? Check your spam folder. If you still can't find it,
53+
use the form above to verify your email address and resend.
54+
</small>
55+
</p>
56+
</div>
57+
{% endblock %}

0 commit comments

Comments
 (0)