Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Redirect when "Email Not Verified"

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

It's cool that we can listen to the CheckPassportEvent and cause authentication to fail by throwing any authentication exception, like this CustomUserMessageAuthenticationException:

... lines 1 - 8
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
... lines 10 - 12
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
... lines 16 - 26
if (!$user->getIsVerified()) {
throw new CustomUserMessageAuthenticationException(
'Please verify your account before logging in.'
);
}
}
... lines 33 - 39
}

But what if, instead of the normal failure behavior - where we redirect to the login page and show the error - we want to do something different. What if, in just this situation, we want to redirect to a totally different page so we can explain that their email isn't verified... and maybe even allow them to resend that email.

Well, unfortunately, there is no way - on this event - to control the failure response. There's no $event->setResponse() or anything like that.

So we can't control the error behavior from here, but we can control it by listening to a different event. We'll "signal" from this event that the account wasn't verified, look for that signal from a different event listener, and redirect to that other page. It's ok if this doesn't make sense yet: let's see it in action.

Creating a Custom Exception Class

To start, we need to create a custom authentication exception class. This will serve as the "signal" that we're in this "account not verified" situation.

In the Security/ directory, add a new class: how about AccountNotVerifiedAuthenticationException. Make it extend AuthenticationException. And then... do absolutely nothing else:

... lines 1 - 2
namespace App\Security;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class AccountNotVerifiedAuthenticationException extends AuthenticationException
{
}

This is just a marker class we'll use to hint that we're failing authentication due to an unverified email.

Back in the subscriber, replace the CustomUserMessageAuthenticationException with AccountNotVerifiedAuthenticationException. We don't need to pass it any message:

... lines 1 - 5
use App\Security\AccountNotVerifiedAuthenticationException;
... lines 7 - 13
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
{
... lines 18 - 27
if (!$user->getIsVerified()) {
throw new AccountNotVerifiedAuthenticationException();
}
}
... lines 32 - 38
}

If we stopped right now, this won't be very interesting. Logging in still fails, but we're back to the generic message:

An authentication exception occurred

This is because our new custom class extends AuthenticationException... and that's the generic message you get from that class. So this isn't what we want yet, but step 1 is done!

Listening to LoginFailureEvent

For the next step, remember from the debug:event command that one of the listeners we have is for a LoginFailureEvent, which, as the name suggests, is called any time that authentication fails.

Let's add another listener right in this class for that. Say LoginFailureEvent::class set to, how about, onLoginFailure. In this case, the priority won't matter:

... lines 1 - 12
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 17 - 38
public static function getSubscribedEvents()
{
return [
... line 42
LoginFailureEvent::class => 'onLoginFailure',
];
}
}

Add the new method: public function onLoginFailure()... and we know this will receive a LoginFailureEvent argument. Just like before, start with dd($event) to see what it looks like:

... lines 1 - 12
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 17 - 33
public function onLoginFailure(LoginFailureEvent $event)
{
dd($event);
}
... lines 38 - 45
}

So with any luck, if we fail login - for any reason - our listener will be called. For example, if I enter a bad password, yup! It gets hit. And notice that the LoginFailureEvent has an exception property. In this case, it holds a BadCredentialsException.

Now log in with the correct password and... it got hit again. But this time, check out the exception. It's our custom AccountNotVerifiedAuthenticationException! So the LoginFailureEvent object contains the authentication exception that caused the failure. We can use that to know - from this method - if authentication failed due to the account not being verified.

Redirecting when Account is Not Verified

So, if not $event->getException() is an instance of AccountNotVerifiedAuthenticationException, then just return and allow the default failure behavior to do its thing:

... lines 1 - 14
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 17 - 33
public function onLoginFailure(LoginFailureEvent $event)
{
if (!$event->getException() instanceof AccountNotVerifiedAuthenticationException) {
return;
}
}
... lines 40 - 47
}

Finally, down here, we know that we should redirect to that custom page. Let's... go create that page real quick. Do it in src/Controller/RegistrationController.php. Down at the bottom, add a new method. I'll call it resendVerifyEmail(). Above this, add @Route() with, how about /verify/resend and name equals app_verify_resend_email. Inside, I'm just going to render a template: return $this->render(), registration/resend_verify_email.html.twig:

... lines 1 - 16
class RegistrationController extends AbstractController
{
... lines 19 - 88
/**
* @Route("/verify/resend", name="app_verify_resend_email")
*/
public function resendVerifyEmail()
{
return $this->render('registration/resend_verify_email.html.twig');
}
}

Let's go make that! Inside of templates/registration/, create resend_verify_email.html.twig. I'll paste in the template:

{% extends 'base.html.twig' %}
{% block title %}Verify Email{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<h1 class="h3 mb-3 font-weight-normal">Verify your Email</h1>
<p>
A verification email was sent - please click it to enable your
account before logging in.
</p>
<a href="#" class="btn btn-primary">Re-send Email</a>
</div>
</div>
</div>
{% endblock %}

There's nothing fancy here at all. It just explains the situation.

I did include a button to resend the email, but I'll leave the implementation to you. I'd probably surround it with a form that POSTs to this URL. And then, in the controller, if the method is POST, I’d use the verify email bundle to generate a new link and re-send it. Basically the same code we used after registration.

Anyways, now that we have a functional page, copy the route name and head back to our subscriber. To override the normal failure behavior, we can use a setResponse() method on the event.

Start with $response = new RedirectResponse() - we're going to generate a URL to the route in a minute - then $event->setResponse($response):

... lines 1 - 16
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 19 - 42
public function onLoginFailure(LoginFailureEvent $event)
{
if (!$event->getException() instanceof AccountNotVerifiedAuthenticationException) {
return;
}
$response = new RedirectResponse(
... line 50
);
$event->setResponse($response);
}
... lines 54 - 61
}

To generate the URL, we need a __construct() method - let me spell that correctly - with a RouterInterface $router argument. Hit Alt+Enter and go to "Initialize properties" to create that property and set it:

... lines 1 - 8
use Symfony\Component\Routing\RouterInterface;
... lines 10 - 16
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
private RouterInterface $router;
public function __construct(RouterInterface $router)
{
$this->router = $router;
}
... lines 25 - 61
}

Back down here, we're in business: $this->router->generate() with app_verify_resend_email:

... lines 1 - 16
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 19 - 42
public function onLoginFailure(LoginFailureEvent $event)
{
... lines 45 - 48
$response = new RedirectResponse(
$this->router->generate('app_verify_resend_email')
);
... line 52
}
... lines 54 - 61
}

Donezo! We fail authentication, our first listener throws the custom exception, we look for that exception from the LoginFailureEvent listener... and set the redirect.

Testing time! Refresh and... got it! We're sent over to /verify/resend. I love that!

Next: let's finish this tutorial by doing something super cool, super fun, and... kinda nerdy. Let's add two-factor authentication, complete with fancy QR codes.

Leave a comment!

18
Login or Register to join the conversation
akincer Avatar

If one wanted to expand the forced verification beyond the login event to ensure no access to any resources would be accessed by an account that isn't verified would the better way be to create a listener for kernel.request? I have code working doing this but would prefer to know ahead of time if I'm going to cause problems down the road. Here's what I did so far.

services.yaml


App\Listeners\UserListener:
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest}

UserListener.php


public function onKernelRequest(RequestEvent $event)
{
// Ignore sub requests
if ($event->getRequestType() != HttpKernel::MAIN_REQUEST)
{
// Don't process further
return;
}


$user = $this->security->getUser();
if (!($user instanceof User))
{
// User object not yet created
return;
}



// Check whether logged-in user hasn't verified their account yet
if (!$user->isVerified())
{
// Prevent circular redirects

if ($event->getRequest()->attributes->get('_controller') != 'App\Controller\RegistrationController::resendVerifyEmail')
{
$event->setResponse(new RedirectResponse($this->router->generate('app_verify_resend_email')));
return;
}
}
}

Reply
akincer Avatar

Note this is some legacy code from a proof of concept I built a while back. I tested moving it into the CheckVerifiedUserSubscriber subscribed events and that seems to work fine. Not sure it makes sense to wrap this into that class given that event is more broad in potential use but I'm guessing it would be best to keep the work happening on each request to a minimum.

Reply
Default user avatar

Hi ! Thanks for the tutorial ! It was precious for me !

So I did all like you but now, I've always a LoginFailureEvent even thought I click on the link into the confirmation mail to verify email address 😭 I'm in a magic loop 😅

Reply

Hey Chris

Could you tell me more info about the error. What error message did you get? Does it happen on every request? If so, there must be something wrong in your authenticator's supports() method

Cheers!

Reply
Default user avatar

Hi Diego Aguiar !

Thanks for replying me !
I will try to explain with my bad english my problem 😅

So, I installed the bundle verify-email-bundle : https://github.com/SymfonyC... on my project. It's work fine ! Cool !

But a user not verified can log in. There is not a deny access for the not verified user.

Therefore, I found your tuto and I'm trying to develop it in my project.

It's OK, all users not verified are redirect to the resending email confirmation page "/verify/resend_email".
But, when users come from the link of the email confirmation, they are rejected too after logging in.
"An authentication exception occurred."
This link needs user to log in before being verified.

/eventSubscriber/CheckVerifiedUserSubscriber.php :


class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
private RouterInterface $router;
private RequestStack $requestStack;

public function __construct(RouterInterface $router, RequestStack $requestStack)
{
$this->router = $router;
$this->requestStack = $requestStack;
}

public function onCheckPassport(CheckPassportEvent $event)
{
$passport = $event->getPassport();

$user = $passport->getUser();
if (!$user instanceof User) {
throw new \Exception('Unexpected user type');
}

//Si l'utilisateur n'est pas vérifié, l'authentification échoue
if (!$user->isVerified()) {
throw new AccountNotVerifiedAuthenticationException;
}
}

public function onLoginFailure(LoginFailureEvent $event)
{
if (!$event->getException() instanceof AccountNotVerifiedAuthenticationException) {
return;
}

$request = $this->requestStack->getCurrentRequest();
$targetPath = $request->getSession()->get('_security.main.target_path');
// dd($targetPath);
if (strpos($targetPath, '/verify/email?') !== false) {
return new RedirectResponse($targetPath);
} else {
$passport = $event->getPassport();

$user = $passport->getUser();
if (!$user instanceof User) {
throw new \Exception('Unexpected user type');
}

$email = $user->getEmail();
$request->getSession()->set('non_verified_email', $email);

$response = new RedirectResponse(
$this->router->generate('app_verify_resend_email')
);
$event->setResponse($response);
}
}

public static function getSubscribedEvents()
{
return [
CheckPassportEvent::class => ['onCheckPassport', -10],
LoginFailureEvent::class => 'onLoginFailure',
];
}
}

src/Controller/RegistrationController.php :


#[Route('/verify/resend_email', name: 'app_verify_resend_email')]
public function resendVerifyEmail(Request $request, UserRepository $userRepository)
{
if ($request->isMethod('POST')) {
$email = $request->getSession()->get('non_verified_email');
$user = $userRepository->findOneBy(['email' => $email]);
if (!$user) {
throw $this->createNotFoundException('user not found for email');
}
// generate a signed url and email it to the user
$this->emailVerifier->sendEmailConfirmation(
'app_verify_email',
$user,
(new TemplatedEmail())
->from(new Address($_ENV['MAILER_USER'], 'Avisa Partners'))
->to($user->getEmail())
->subject('Please Confirm your Email')
->htmlTemplate('registration/confirmation_email.html.twig')
);

$this->addFlash('success', 'An email has been sended to you. Please click on the link into.');

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

return $this->render('registration/resend_verify_email.html.twig');
}

Do you have an idea for bypass the onLoginFailure when the user click on the link of the confirmation email ?
Because I don't want bypass the log in form before verification for security.

I hope to be understandable 🙏🏻😬

Thanks !

Reply
Default user avatar

Maybe the only way is to verify their email address without having to be authenticated ?
What do you think ? 😑

Reply

Hey Chris

I noticed the bundle support both ways, validating anon and logged in users. You need to decide first what method you want to leverage and then add the code shown in their docs https://github.com/SymfonyC...

I hope it helps. Cheers!

Reply
Default user avatar

Hey Diego Aguiar !

Yes I fixed my issue with the anonymous validation. It's Ok now :)

Thanks for your help !

Cheers !

1 Reply
Leonel D. Avatar
Leonel D. Avatar Leonel D. | posted 4 months ago

Hi, thanks for the tutorial, but please
"I did include a button to resend the email, but I'll leave the implementation to you." don't say that haha... i don't know what i have to do. I can't understand neither how who commented before me did it. Could you please post the code to re-send the link?, i mean.. It's something what we expected to learn watching this tutorials. Thanks you and have a have a good weekend :)

Reply

Hey Leonel Denett !

Fair enough :). Here is what I would do:

A) From inside of CheckVerifiedUserSubscriber - https://symfonycasts.com/sc... - inside of onLoginFailure(), I would set the email of the user into the session. Something like:


public function onLoginFailure(LoginFailureEvent $event)
{
if (!$event->getException() instanceof AccountNotVerifiedAuthenticationException) {
return;
}

// ADD these 3 lines!
$request = $event->getRequest();
$email = $event->getPassport()->getUser()->getEmail();
$request->getSession()->set('non_verified_email', $email);

$response = new RedirectResponse(
$this->router->generate('app_verify_resend_email')
);
$event->setResponse($response);
}

Then, in resend_verify_email.html.twig, for the "Re-Send Email" link, I would actually make that form with a button so that it POSTs back to the controller when pressed:


<form method="POST">
<button type="submit" class="btn btn-primary">Re-send Email</button>
</form>

Finally, in the resendVerifyEmail() controller method, IF the method is POST, let's re-send the email:


/**
* @Route("/verify/resend", name="app_verify_resend_email")
*/
public function resendVerifyEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository)
{
if ($request->isMethod('POST')) {
// resend the email here
// probably, you will want to centralize all of this logic
// into a service so you can call it from here and also
// from RegistrationController after a successful registration

$email = $request->getSession()->get('non_verified_email');
$user = $userRepository->findOneBy(['email' => $email]);
if (!$user) {
throw $this->createNotFoundException('user not found for email');
}

$signatureComponents = $verifyEmailHelper->generateSignature(
'app_verify_email',
$user->getId(),
$user->getEmail(),
['id' => $user->getId()]
);

// TODO: in a real app, send this as an email!
$this->addFlash('success', 'Confirm your email at: '.$signatureComponents->getSignedUrl());

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

return $this->render('registration/resend_verify_email.html.twig');
}

Let me know if this helps :).

Cheers!

1 Reply
Tristan P. Avatar

Thanks for sharing your approach.

I have got one thing to add:

In CheckVerifiedUserSubscriber class you have to add RequestStack $requestRequeststack to the Constructor so that you can use the current Request in the method of the subscriber via $request = $this->requestStack->getCurrentRequest();

Then I also got a question. How can I get the User in the resendVerifyEmail(9 controller method?

Since we are not verified at this point, we can NOT use the controllers $this->getUser() helper an can not access the private non_verified_email property of the session, or am I wrong?

Thanks!

1 Reply

Hey Tristano Milano!

> In CheckVerifiedUserSubscriber class you have to add RequestStack $requestRequeststack to the Constructor so that you can use the current Request in the method of the subscriber via $request = $this->requestStack->getCurrentRequest();

Ah, you're right! Thank you - I updated my code above. Actually, you can get the Request even easier with $event->getRequest() on this case.

And, I can see that you answered your other question, but I can see that it wasn't clear in my code. I'll update that as well!

Cheers!

1 Reply
Tristan P. Avatar

Ok, I am sorry. I can of course get the user in the controller method, I just have get the email from the session via




$session->get('non_verified_email');


Then I can use the repo to get the user.

I just hat a typo when trying to get the property from the session

1 Reply
Ruslan I. Avatar
Ruslan I. Avatar Ruslan I. | posted 5 months ago

"I did include a button to resend the email, but I'll leave the implementation to you."

I used the AuthenticationUtils to get the last username to find out who is trying to resend email. And then made a request via UserRepository (findOneBy) and generated new link.

Did I do it right? :)

Reply

Hey there!

Sounds good! But because the last username is in session and it's not robust solution probably. If the users is authenticated - you can try to get it from the controller. If you extends the Symfony abstract controller - you can get the current user with getUser() call. Otherwise, you can inject the Security service that will bring you the same getUser() method to fetch. the currently authenticated user object.

Cheers!

Reply
Khaled L. Avatar
Khaled L. Avatar Khaled L. | posted 8 months ago

Hi ,
how can I pass the user to the resend route in register controller so i can after that resend the email to him if he click the resend bouton.
i don't pass it into the url since bad personnes can change it and resend verification email to another users .
any solution to do it securely please ?

Reply

Hey Khaled Lajili!

That's a really good question! A simple solution would be to, from the subscriber we create, store the user's id into the session. Then in the resend action, read it from the session (404 if nothing is there), query for the User and re-send the email.

Cheers!

Reply
discipolat Avatar

Hi...Works pretty fine. Thanks !

2 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}