Skip to content
Open
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
128 changes: 125 additions & 3 deletions src/Controller/RegistrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
use App\Entity\User;
use App\Entity\UserRepository;
use App\Form\RegistrationFormType;
use App\Form\UpdateEmailFormType;
use App\Security\BruteForceLoginFormAuthenticator;
use App\Security\EmailVerifier;
use App\Security\UserChecker;
use Psr\Clock\ClockInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand All @@ -30,7 +32,7 @@

class RegistrationController extends Controller
{
public function __construct(private EmailVerifier $emailVerifier)
public function __construct(private EmailVerifier $emailVerifier, private string $internalSecret, private ClockInterface $clock)
{
}

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

return $this->redirectToRoute('home');
// Redirect to confirmation page with signed token
$token = $this->generateRegistrationToken($user);

return $this->redirectToRoute('register_check_email', ['token' => $token]);
}

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

#[Route(path: '/register/check-email/{token}', name: 'register_check_email')]
public function checkEmailConfirmation(string $token, UserRepository $userRepository): Response
{
$result = $this->validateRegistrationToken($token, $userRepository);

if ($result === null) {
$this->addFlash('error', 'This link is invalid or has expired. Please register again.');
return $this->redirectToRoute('register');
}

$form = $this->createForm(UpdateEmailFormType::class, $result['user']);

return $this->render('registration/check_email.html.twig', [
'email' => $result['email'],
'token' => $token,
'form' => $form,
]);
}

#[Route(path: '/register/resend/{token}', name: 'register_resend', methods: ['POST'])]
public function resendConfirmation(string $token, Request $request, UserRepository $userRepository, string $mailFromEmail, string $mailFromName): Response
{
$result = $this->validateRegistrationToken($token, $userRepository);

if ($result === null) {
$this->addFlash('error', 'This link is invalid or has expired. Please register again.');
return $this->redirectToRoute('register');
}

$user = $result['user'];
$form = $this->createForm(UpdateEmailFormType::class, $user);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
// Persist email change if it was modified
$this->getEM()->flush();

// Resend confirmation email
$this->emailVerifier->sendEmailConfirmation(
'register_confirm_email',
$user,
new TemplatedEmail()
->from(new Address($mailFromEmail, $mailFromName))
->to($user->getEmail())
->subject('Please confirm your email')
->htmlTemplate('registration/confirmation_email.html.twig')
->textTemplate('registration/confirmation_email.txt.twig')
);

// Generate new token with updated email
$newToken = $this->generateRegistrationToken($user);

$this->addFlash('success', 'Confirmation email has been sent to ' . $user->getEmail());

return $this->redirectToRoute('register_check_email', ['token' => $newToken]);
}

// If form is invalid, redisplay the page with errors
return $this->render('registration/check_email.html.twig', [
'email' => $user->getEmail(),
'token' => $token,
'form' => $form,
]);
}

/**
* @param BruteForceLoginFormAuthenticator<User> $authenticator
*/
Expand Down Expand Up @@ -119,4 +188,57 @@ public function confirmEmail(Request $request, UserRepository $userRepository, U

return $this->redirectToRoute('home');
}

private function generateRegistrationToken(User $user): string
{
$timestamp = $this->clock->now()->getTimestamp();
$data = $user->getId() . '|' . $user->getEmail() . '|' . $timestamp;
$signature = hash_hmac('sha256', $data, $this->internalSecret);

return base64_encode($data . '|' . $signature);
}

/**
* @return array{user: User, email: string}|null
*/
private function validateRegistrationToken(string $token, UserRepository $userRepository): ?array
{
$decoded = base64_decode($token, true);
if ($decoded === false) {
return null;
}

$parts = explode('|', $decoded);
if (count($parts) !== 4) {
return null;
}

[$userId, $email, $timestamp, $providedSignature] = $parts;

// Check expiration (10 minutes = 600 seconds)
$now = $this->clock->now()->getTimestamp();
if ($now - (int) $timestamp > 600) {
return null;
}

// Verify signature
$data = $userId . '|' . $email . '|' . $timestamp;
$expectedSignature = hash_hmac('sha256', $data, $this->internalSecret);
if (!hash_equals($expectedSignature, $providedSignature)) {
return null;
}

// Load user
$user = $userRepository->find((int) $userId);
if ($user === null) {
return null;
}

// Check if user is already enabled
if ($user->isEnabled()) {
return null;
}

return ['user' => $user, 'email' => $email];
}
}
42 changes: 42 additions & 0 deletions src/Form/UpdateEmailFormType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <j.boggiano@seld.be>
* Nils Adermann <naderman@naderman.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* Used to update the email post-registration and before email has been confirmed, in case of typos
*
* @extends AbstractType<User>
*/
class UpdateEmailFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, ['empty_data' => ''])
;
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
'validation_groups' => ['Default', 'Registration'],
]);
}
}
57 changes: 57 additions & 0 deletions templates/registration/check_email.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{% extends 'user/layout.html.twig' %}

{% block title %}Check your email - {{ parent() }}{% endblock %}

{% block user_content %}
<div class="col-md-12">
<h2 class="title">
Check your email
</h2>
</div>

<div class="col-md-8">
<div class="alert alert-success" role="alert">
<strong>Success!</strong> Your account has been created. We've sent a confirmation email to verify your address.
</div>

<p>
We've sent a confirmation email to <strong>{{ email }}</strong>.
Please check your inbox and click the link in the email to activate your account.
</p>

<hr>

<h4>Made a typo?</h4>
<p>If you notice an error in your email address, you can correct it below and resend the confirmation email.</p>

{{ form_start(form, {
action: path('register_resend', {token: token}),
attr: {class: "form-horizontal"}
}) }}
<div class="form-group">
<label for="{{ form.email.vars.id }}" class="col-sm-3 control-label">Email address:</label>
<div class="col-sm-9">
{{ form_errors(form.email) }}
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
</div>
</div>

<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-primary">
<span class="glyphicon glyphicon-envelope"></span> Update &amp; Resend Confirmation Email
</button>
<br>
<strong>Note:</strong> This link is valid for 10 minutes.
</div>
</div>
{{ form_end(form) }}

<p class="text-muted">
<small>
Didn't receive the email? Check your spam folder. If you still can't find it,
use the form above to verify your email address and resend.
</small>
</p>
</div>
{% endblock %}
Loading