Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Redirección personalizada cuando "Email no verificado"

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

Es 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.

Crear una clase de excepción personalizada

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!

Escuchar el evento LoginFailureEvent

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.

Redirigir cuando 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.

Leave a comment!

¡Este tutorial también funciona muy bien para 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
    }
}