Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Cuando falla la autenticación

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

Vuelve al formulario de inicio de sesión. ¿Qué ocurre si falla el inicio de sesión? En este momento, hay dos formas de fallar: si no podemos encontrar un User para el correo electrónico o si la contraseña es incorrecta. Probemos primero con una contraseña incorrecta.

onAuthenticationFailure & AuthenticationException

Introduce un correo electrónico real de la base de datos... y luego cualquier contraseña que no sea "tada". Y... ¡sí! Nos encontramos con el dd()... que viene de onAuthenticationFailure():

... lines 1 - 19
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 22 - 64
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
dd('failure');
}
... lines 69 - 79
}

Así que, independientemente de cómo fallemos la autenticación, acabamos aquí, y se nos pasa un argumento$exception. También vamos a tirar eso:

... lines 1 - 19
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 22 - 64
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
dd('failure', $exception);
}
... lines 69 - 79
}

Vuelve... y actualiza. ¡Genial! Es un BadCredentialsException.

Esto es genial. Si la autenticación falla -no importa cómo falle- vamos a acabar aquí con algún tipo de AuthenticationException. BadCredentialsException es una subclase de ese .... al igual que el UserNotFoundException que estamos lanzando desde nuestro callback del cargador de usuarios:

... lines 1 - 19
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 22 - 35
public function authenticate(Request $request): PassportInterface
{
... lines 38 - 40
return new Passport(
new UserBadge($email, function($userIdentifier) {
... lines 43 - 45
if (!$user) {
throw new UserNotFoundException();
}
... lines 49 - 50
}),
... lines 52 - 54
);
}
... lines 57 - 79
}

Todas estas clases de excepción tienen algo importante en común. Mantén pulsado Command oCtrl para abrir UserNotFoundException y verlo. Todas estas excepciones de autenticación tienen un método especial getMessageKey() que contiene una explicación segura de por qué ha fallado la autenticación. Podemos utilizarlo para informar al usuario de lo que ha ido mal.

hide_user_not_found: Mostrar errores de nombre de usuario/correo electrónico no válidos

Así que esto es lo más importante: cuando la autenticación falla, es porque algo ha lanzado un AuthenticationException o una de sus subclases. Y así, como estamos lanzando un UserNotFoundException cuando se introduce un correo electrónico desconocido... si intentamos iniciar la sesión con un correo electrónico incorrecto, esa excepción debería pasarse aonAuthenticationFailure().

Vamos a probar esa teoría. En el formulario de inicio de sesión, introduce un correo electrónico inventado... y... envía. ¡Ah! Seguimos obteniendo un BadCredentialsException! Esperaba que ésta fuera la verdadera excepción lanzada: la UserNotFoundException.

En la mayoría de los casos... así es como funciona. Si lanzas unAuthenticationException durante el proceso de autentificación, esa excepción se te pasa a onAuthenticationFailure(). Entonces puedes utilizarla para averiguar qué ha ido mal. Sin embargo, UserNotFoundException es un caso especial. En algunos sitios, cuando el usuario introduce una dirección de correo electrónico válida pero una contraseña incorrecta, puede que no quieras decirle al usuario que, de hecho, se encontró el correo electrónico. Así que dices "Credenciales no válidas" tanto si no se encontró el correo electrónico como si la contraseña era incorrecta.

Este problema se llama enumeración de usuarios: es cuando alguien puede probar los correos electrónicos en tu formulario de acceso para averiguar qué personas tienen cuentas y cuáles no. Para algunos sitios, definitivamente no quieres exponer esa información.

Por eso, para estar seguros, Symfony convierte UserNotFoundException en unBadCredentialsException para que introducir un correo electrónico o una contraseña no válidos dé el mismo mensaje de error. Sin embargo, si quieres poder decir "Correo electrónico no válido" -lo que es mucho más útil para tus usuarios- puedes hacer lo siguiente

Abre config/packages/security.yaml. Y, en cualquier lugar bajo la clave raíz security, añade una opción hide_user_not_found establecida como false:

security:
... lines 2 - 4
hide_user_not_found: false
... lines 6 - 37

Esto le dice a Symfony que no convierta UserNotFoundException en un BadCredentialsException.

Si refrescamos ahora... ¡boom! Nuestro UserNotFoundException se pasa ahora directamente a onAuthenticationFailure().

Almacenamiento del error de autenticación en la sesión

Bien, pensemos. En onAuthenticationFailure()... ¿qué queremos hacer? Nuestro trabajo en este método es, como puedes ver, devolver un objeto Response. Para un formulario de inicio de sesión, lo que probablemente queramos hacer es redirigir al usuario de vuelta a la página de inicio de sesión, pero mostrando un error.

Para poder hacerlo, vamos a guardar esta excepción -que contiene el mensaje de error- en la sesión. Digamos $request->getSession()->set(). En realidad podemos utilizar la clave que queramos... pero hay una clave estándar que se utiliza para almacenar los errores de autenticación. Puedes leerla desde una constante: Security - la del componente de seguridad de Symfony - ::AUTHENTICATION_ERROR. Pasa $exception al segundo argumento:

... lines 1 - 20
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 23 - 65
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception);
... lines 69 - 72
}
... lines 74 - 84
}

Ahora que el error está en la sesión, vamos a redirigirnos a la página de inicio de sesión. Haré trampa y copiaré el RedirectResponse de antes... y cambiaré la ruta aapp_login:

... lines 1 - 20
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 23 - 65
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception);
return new RedirectResponse(
$this->router->generate('app_login')
);
}
... lines 74 - 84
}

AuthenticationUtils: Renderizando el error

¡Genial! A continuación, dentro del controlador login(), tenemos que leer ese error y renderizarlo. La forma más directa de hacerlo sería coger la sesión y leer esta clave. Pero... ¡es incluso más fácil que eso! Symfony proporciona un servicio que tomará la clave de la sesión automáticamente. Añade un nuevo argumento de tipoAuthenticationUtils:

... lines 1 - 7
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
... lines 12 - 14
public function login(AuthenticationUtils $authenticationUtils): Response
{
... lines 17 - 19
}
}

Y luego dale a render() un segundo argumento. Vamos a pasar una variable error a Twig configurada como $authenticationUtils->getLastAuthenticationError():

... lines 1 - 9
class SecurityController extends AbstractController
{
... lines 12 - 14
public function login(AuthenticationUtils $authenticationUtils): Response
{
return $this->render('security/login.html.twig', [
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
}

Eso es sólo un atajo para leer esa clave de la sesión.

Esto significa que la variable error va a ser literalmente un objetoAuthenticationException. Y recuerda, para averiguar qué ha ido mal, todos los objetos AuthenticationException tienen un método getMessageKey() que devuelve una explicación.

En templates/security/login.html.twig, vamos a devolver eso. Justo después del h1, digamos que si error, entonces añade un div con alert alert-danger. Dentro renderizaerror.messageKey:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<form method="post" class="row g-3">
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
{% if error %}
<div class="alert alert-danger">{{ error.messageKey }}</div>
{% endif %}
... lines 15 - 29
</form>
</div>
</div>
</div>
{% endblock %}

No quieres usar error.message porque si tuvieras algún tipo de error interno -como un error de conexión a la base de datos- ese mensaje podría contener detalles sensibles. Pero error.messageKey está garantizado que es seguro.

¡Hora de probar! ¡Refrescar! ¡Sí! Somos redirigidos de nuevo a /login y vemos:

No se ha podido encontrar el nombre de usuario.

Ese es el mensaje si no se puede cargar el objeto User: el error que viene de UserNotFoundException. No es un gran mensaje... ya que nuestros usuarios se conectan con un correo electrónico, no con un nombre de usuario.

Así que, a continuación, vamos a aprender a personalizar estos mensajes de error y a añadir una forma de cerrar la sesión.

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