If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeEs genial que podamos escuchar el CheckPassportEvent
y hacer que la autenticación falle lanzando cualquier excepción de autenticación, como estaCustomUserMessageAuthenticationException
:
... 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 | |
} |
Pero ¿qué pasa si, en lugar del comportamiento normal de fallo -en el que redirigimos a la página de inicio de sesión y mostramos el error-, queremos hacer algo diferente? ¿Qué pasa si, justo en esta situación, queremos redirigir a una página totalmente diferente para poder explicar que su correo electrónico no está verificado... y tal vez incluso permitirles reenviar ese correo electrónico?
Bueno, por desgracia, no hay forma -en este evento- de controlar la respuesta de fallo. No hay $event->setResponse()
ni nada parecido.
Así que no podemos controlar el comportamiento del error desde aquí, pero podemos controlarlo escuchando un evento diferente. Desde este evento "señalaremos" que la cuenta no ha sido verificada, buscaremos esa señal desde un oyente de eventos diferente y redirigiremos a esa otra página. No pasa nada si esto aún no tiene sentido: vamos a verlo en acción.
Para empezar, tenemos que crear una clase de excepción de autenticación personalizada. Esto servirá como "señal" de que estamos en esta situación de "cuenta no verificada".
En el directorio Security/
, añade una nueva clase: ¿qué talAccountNotVerifiedAuthenticationException
. Haz que extienda AuthenticationException
. Y luego... no hagas absolutamente nada más:
... lines 1 - 2 | |
namespace App\Security; | |
use Symfony\Component\Security\Core\Exception\AuthenticationException; | |
class AccountNotVerifiedAuthenticationException extends AuthenticationException | |
{ | |
} |
Ésta es sólo una clase marcadora que utilizaremos para indicar que está fallando la autenticación debido a un correo electrónico no verificado.
De vuelta al suscriptor, sustituye el CustomUserMessageAuthenticationException
porAccountNotVerifiedAuthenticationException
. No necesitamos pasarle ningún mensaje:
... 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 | |
} |
Si nos detenemos en este momento, esto no será muy interesante. El inicio de sesión sigue fallando, pero volvemos al mensaje genérico:
Se ha producido una excepción de autenticación
Esto se debe a que nuestra nueva clase personalizada extiende AuthenticationException
... y ese es el mensaje genérico que se obtiene de esa clase. Así que esto no es lo que queremos todavía, ¡pero el paso 1 está hecho!
Para el siguiente paso, recuerda del comando debug:event
que uno de los escuchadores que tenemos es para un LoginFailureEvent
, que, como su nombre indica, se llama cada vez que falla la autenticación.
Vamos a añadir otro oyente en esta clase para eso. Digamos queLoginFailureEvent::class
se ajusta a, qué tal, onLoginFailure
. En este caso, la prioridad no importará:
... 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', | |
]; | |
} | |
} |
Añade el nuevo método: public function onLoginFailure()
... y sabemos que éste recibirá un argumento LoginFailureEvent
. Al igual que antes, empieza condd($event)
para ver cómo queda:
... 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 | |
} |
Así que, con un poco de suerte, si fallamos en el inicio de sesión -por cualquier motivo- se llamará a nuestro oyente. Por ejemplo, si introduzco una contraseña incorrecta, ¡sí! Se llama. Y fíjate en que elLoginFailureEvent
tiene una propiedad de excepción. En este caso, contiene unBadCredentialsException
.
Ahora entra con la contraseña correcta y... se golpea de nuevo. Pero esta vez, fíjate en la excepción. ¡Es nuestro AccountNotVerifiedAuthenticationException
personalizado! Así que el objeto LoginFailureEvent
contiene la excepción de autenticación que causó el fallo. Podemos utilizarlo para saber -desde este método- si la autenticación falló debido a que la cuenta no está verificada.
Así que, si no $event->getException()
es una instancia deAccountNotVerifiedAuthenticationException
, entonces simplemente devuelve y permite que el comportamiento de fallo por defecto haga lo suyo:
... lines 1 - 14 | |
class CheckVerifiedUserSubscriber implements EventSubscriberInterface | |
{ | |
... lines 17 - 33 | |
public function onLoginFailure(LoginFailureEvent $event) | |
{ | |
if (!$event->getException() instanceof AccountNotVerifiedAuthenticationException) { | |
return; | |
} | |
} | |
... lines 40 - 47 | |
} |
Finalmente, aquí abajo, sabemos que debemos redirigir a esa página personalizada. Vamos... a crear esa página rápidamente. Hazlo en src/Controller/RegistrationController.php
. En la parte inferior, añade un nuevo método. Lo llamaré resendVerifyEmail()
. Encima de esto, añade @Route()
con, qué tal /verify/resend
y el nombre es igual aapp_verify_resend_email
. Dentro, sólo voy a renderizar una plantilla: 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'); | |
} | |
} |
¡Vamos a hacer eso! Dentro de templates/registration/
, crearesend_verify_email.html.twig
. Voy a pegar la plantilla:
{% 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 %} |
Aquí no hay nada del otro mundo. Sólo explica la situación.
He incluido un botón para reenviar el correo electrónico, pero te dejo la implementación a ti. Yo probablemente lo rodearía de un formulario que haga un POST a esta URL. Y luego, en el controlador, si el método es POST, utilizaría el paquete de correo electrónico de verificación para generar un nuevo enlace y reenviarlo. Básicamente, el mismo código que utilizamos tras el registro.
De todos modos, ahora que tenemos una página funcional, copia el nombre de la ruta y vuelve a nuestro suscriptor. Para anular el comportamiento normal de los fallos, podemos utilizar un métodosetResponse()
en el evento.
Empieza con $response = new RedirectResponse()
-vamos a generar una URL para la ruta en un minuto- y luego con $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 | |
} |
Para generar la URL, necesitamos un método __construct()
-permíteme deletrearlo correctamente- con un argumento RouterInterface $router
. Pulsa Alt
+Enter
y ve a "Inicializar propiedades" para crear esa propiedad y establecerla:
... 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 | |
} |
Aquí abajo, estamos en el negocio: $this->router->generate()
conapp_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! Fallamos la autenticación, nuestro primer oyente lanza la excepción personalizada, buscamos esa excepción desde el oyente de LoginFailureEvent
... y establecemos la redirección.
¡Hora de probar! Refresca y... ¡lo tienes! Nos envían a /verify/resend
. ¡Me encanta!
A continuación: vamos a terminar este tutorial haciendo algo superguay, superdivertido y... un poco friki. Vamos a añadir la autenticación de dos factores, completada con elegantes códigos QR.
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;
}
}
}
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.
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 😅
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!
Hi MolloKhan !
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/SymfonyCasts/verify-email-bundle 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 !
Maybe the only way is to verify their email address without having to be authenticated ?
What do you think ? 😑
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!
Hey MolloKhan !
Yes I fixed my issue with the anonymous validation. It's Ok now :)
Thanks for your help !
Cheers !
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 :)
Hey Leonel D. !
Fair enough :). Here is what I would do:
A) From inside of CheckVerifiedUserSubscriber
- https://symfonycasts.com/screencast/symfony-security/login-failure#codeblock-d5a84a00cc - 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!
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!
Hey Tristan P.!
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!
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
"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? :)
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!
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 ?
Hey Khaled L.!
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!
// composer.json
{
"require": {
"php": ">=8.1",
"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
}
}
Hi! Thanks for this great tutorial!
But at the last step - add resending response to CheckVerifiedUserSubscriber - went something wrong.
The same code (but Symfony 6) - but instead of being redirected to "app_verify_resend_email" in the browser - appears this error:
Typed property App\EventSubscriber\CheckVerifiedUserSubscriber::$router must not be accessed before initialization
Any hint how to fix it?