Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Crear un suscriptor de eventos de seguridad

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

Éste es nuestro objetivo: si un usuario intenta iniciar la sesión pero aún no ha verificado su correo electrónico, tenemos que hacer que falle la autenticación.

Si quieres detener la autenticación por alguna razón, probablemente quieras escuchar el CheckPassportEvent: que se llama justo después de que se ejecute el método authenticate() en cualquier autenticador y... su trabajo consiste en hacer cosas como ésta.

Creación del Suscriptor de Eventos

En tu directorio src/, no importa dónde, pero voy a crear un nuevo directorio llamado EventsSubscriber/. Dentro, añade una clase llamadaCheckVerifiedUserSubscriber. Haz que ésta implemente EventSubscriberInterface y luego ve al menú "Código"->"Generar" -o Command+N en un Mac- y dale a "Implementar métodos" para generar el que necesitamos: getSubscribedEvents():

... lines 1 - 2
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
}
}

Dentro, devuelve un array con todos los eventos que queremos escuchar, que es uno solo. Digamos que CheckPassportEvent::class establece el método de esta clase que debe ser llamado cuando se envíe ese evento. Qué tal, onCheckPassport:

... lines 1 - 5
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 10 - 14
public static function getSubscribedEvents()
{
return [
CheckPassportEvent::class => 'onCheckPassport',
];
}
}

Arriba, añade esto: public function onCheckPassport()... y esto recibirá este objeto de evento. Así que CheckPassportEvent $event. Empieza condd($event) para que podamos ver su aspecto:

... lines 1 - 5
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
{
dd($event);
}
... lines 14 - 20
}

Ahora, sólo con crear esta clase y hacer que implementeEventSubscriberInterface, gracias a la función de "autoconfiguración" de Symfony, ya será llamada cuando ocurra el CheckPassportEvent. Y... si quieres ponerte técnico, nuestro suscriptor escucha el CheckPassportEvent en todos los cortafuegos. Para nosotros, sólo tenemos un cortafuegos real, así que no importa:

security:
... lines 2 - 20
firewalls:
dev:
... lines 23 - 24
main:
lazy: true
provider: app_user_provider
entry_point: form_login
login_throttling: true
form_login:
login_path: app_login
check_path: app_login
username_parameter: email
password_parameter: password
enable_csrf: true
custom_authenticator:
# - App\Security\LoginFormAuthenticator
- App\Security\DummyAuthenticator
logout: true
remember_me:
secret: '%kernel.secret%'
signature_properties: [password]
always_remember_me: true
switch_user: true
... lines 49 - 62

Pero si tuvieras varios cortafuegos reales, se llamaría a nuestro suscriptor siempre que se activara el evento para cualquier cortafuegos. Si lo necesitas, puedes añadir una pequeña configuración adicional para dirigirte a uno solo de los cortafuegos.

Ajustar la prioridad de los eventos

De todos modos, ¡probemos esto! Inicia sesión como abraca_admin@example.com. Hemos puesto la bandera isVerified en las instalaciones como verdadera para todos los usuarios... pero aún no hemos recargado la base de datos. Así que este usuario no será verificado.

Intenta escribir una contraseña no válida y enviarla. Sí Ha llegado a nuestro dd(). Así que esto funciona. Pero si escribo un correo electrónico no válido, nuestro escuchador no se ejecuta. ¿Por qué?

Tanto la carga del usuario como la comprobación de la contraseña ocurren a través de oyentes del CheckPassportEvent: el mismo evento que estamos escuchando. La incoherencia en el comportamiento -el hecho de que nuestro oyente se haya ejecutado con una contraseña no válida pero no con un correo electrónico no válido- se debe a la prioridad de los oyentes.

Vuelve a tu terminal. Ah, cada evento muestra una prioridad, y el valor por defecto es cero. Déjame hacer esto un poco más pequeño para que podamos leerlo. Ya está.

Fíjate bien: nuestro oyente es llamado antes que el CheckCredentialsListener. Por eso llamó a nuestro oyente antes de que la comprobación de la contraseña pudiera fallar.

Pero eso no es lo que queremos. No queremos hacer nuestra comprobación "está verificada" hasta que sepamos que la contraseña es válida: no hay razón para exponer si la cuenta está verificada o no hasta que sepamos que el usuario real está iniciando la sesión.

La cuestión es: queremos que nuestro código se ejecute después de CheckCredentialsListener. Para ello, podemos dar a nuestro oyente una prioridad negativa. Modifica la sintaxis: establece el nombre del evento en una matriz con el nombre del método como primera clave y la prioridad como segunda. ¿Qué tal un 10 negativo?

... lines 1 - 7
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 10 - 14
public static function getSubscribedEvents()
{
return [
CheckPassportEvent::class => ['onCheckPassport', -10],
];
}
}

Gracias a esto, el usuario tendrá que introducir un correo electrónico y una contraseña válidos antes de que se llame a nuestro oyente. Pruébalo: vuelve a abraca_admin@example.com, contraseña tada y... ¡hermoso!

Utilizar el objeto de evento

Echa un vistazo al objeto de evento que nos han pasado: está lleno de cosas buenas. Contiene el autentificador que se utilizó, por si necesitamos hacer algo diferente en función de eso. También contiene el Passport... que es enorme porque contiene el objetoUser y las insignias... porque a veces necesitas hacer cosas diferentes en función de las insignias del pasaporte.

Dentro de nuestro suscriptor, pongámonos a trabajar. Para obtener el usuario, primero tenemos que obtener el pasaporte: $passport = $event->getPassport(). Ahora, añade si no$passport es un instanceof UserPassportInterface, lanza una excepción:

... lines 1 - 6
use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface;
... lines 8 - 9
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
{
$passport = $event->getPassport();
if (!$passport instanceof UserPassportInterface) {
throw new \Exception('Unexpected passport type');
}
}
... lines 19 - 25
}

Esta comprobación no es importante y no es necesaria en Symfony 6 y superiores. Básicamente, esta comprobación asegura que nuestro Passport tiene un método getUser(), que en la práctica, siempre lo tendrá. En Symfony 6, la comprobación no es necesaria en absoluto porque la clasePassport tiene literalmente siempre este método.

Esto significa que, aquí abajo, podemos decir $user = $passport->getUser(). Y luego añadamos una comprobación de cordura: si $user no es una instancia de nuestra clase User, lanza una excepción: "Tipo de usuario inesperado":

... lines 1 - 4
use App\Entity\User;
... lines 6 - 10
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
{
$passport = $event->getPassport();
if (!$passport instanceof UserPassportInterface) {
throw new \Exception('Unexpected passport type');
}
$user = $passport->getUser();
if (!$user instanceof User) {
throw new \Exception('Unexpected user type');
}
}
... lines 25 - 31
}

En la práctica, en nuestra aplicación, esto no es posible. Pero es una buena forma de dar una pista a mi editor -o a las herramientas de análisis estático- de que $user es nuestra clase Usuario. Gracias a esto, cuando digamos if not $user->getIsVerified(), se autocompletará ese método:

... lines 1 - 11
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
{
$passport = $event->getPassport();
if (!$passport instanceof UserPassportInterface) {
throw new \Exception('Unexpected passport type');
}
$user = $passport->getUser();
if (!$user instanceof User) {
throw new \Exception('Unexpected user type');
}
if (!$user->getIsVerified()) {
... line 27
}
}
... lines 30 - 36
}

Fallo de autenticación

Bien, si no estamos verificados, tenemos que hacer que falle la autenticación. ¿Cómo lo hacemos? Resulta que, en cualquier momento del proceso de autenticación, podemos lanzar un AuthenticationException -de Seguridad- y eso hará que la autenticación falle:

... lines 1 - 7
use Symfony\Component\Security\Core\Exception\AuthenticationException;
... lines 9 - 11
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
{
... lines 16 - 25
if (!$user->getIsVerified()) {
throw new AuthenticationException();
}
}
... lines 30 - 36
}

Y hay un montón de subclases de esta clase, como BadCredentialsException. Puedes lanzar cualquiera de ellas porque todas extienden a AuthenticationException.

Compruébalo. Actualicemos y... ¡ya está!

Se ha producido una excepción de autenticación.

Ese es el mensaje de error genérico vinculado a la clase AuthenticationException... no es un mensaje de error muy bueno. Pero ha hecho el trabajo.

¿Cómo podemos personalizarlo? O bien lanzando una excepción de autenticación diferente que coincida con el mensaje que quieres -como BadCredentialsException - o bien tomando el control total lanzando la clase especialCustomUserMessageAuthenticationException(). Pásale este mensaje para que se lo muestre al usuario:

Por favor, verifica tu cuenta antes de iniciar la sesión.

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

Veamos cómo funciona esto. Mantén Cmd o Ctrl y haz clic para abrir esta clase. No es ninguna sorpresa: extiende AuthenticationException. Si intentas pasar un mensaje de excepción personalizado a AuthenticationException o a una de sus subclases, normalmente ese mensaje no se mostrará al usuario.

Esto se debe a que cada clase de excepción de autenticación tiene un método getMessageKey()que contiene un mensaje codificado... y eso es lo que se muestra al usuario. Esto se hace por seguridad, para que no expongamos accidentalmente algún mensaje de excepción interno a nuestros usuarios. Por eso, las diferentes subclases de excepción de autenticación nos dan mensajes diferentes.

Sin embargo, hay algunos casos en los que quieres mostrar un mensaje realmente personalizado. Puedes hacerlo utilizando esta clase. Esto fallará la autenticación igual que antes, pero ahora nosotros controlamos el mensaje. Muy bonito.

¡Pero podemos hacerlo aún mejor! En lugar de decir simplemente "por favor, verifique su cuenta", redirijamos al usuario a otra página en la que podamos explicarle mejor por qué no puede iniciar la sesión y darle la oportunidad de volver a enviar el correo electrónico. Esto requerirá una segunda escucha y un serio trabajo en equipo. Eso es lo siguiente.

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
    }
}