Buy Access to Course
09.

When Authentication Fails

|

Share this awesome video!

|

Keep on Learning!

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

Login Subscribe

Go back to the login form. What happens if we fail login? Right now, there are two ways to fail: if we can't find a User for the email or if the password is incorrect. Let's try a wrong password first.

onAuthenticationFailure & AuthenticationException

Enter a real email from the database... and then any password that isn't "tada". And... yep! We hit the dd()... that comes from onAuthenticationFailure():

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

So no matter how we fail authentication, we end up here, and we're passed an $exception argument. Let's also dump that:

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

Head back... and refresh. Cool! It's a BadCredentialsException.

This is cool. If authentication fails - no matter how it fails - we're going to end up here with some sort of AuthenticationException. BadCredentialsException is a subclass of that.... as is the UserNotFoundException that we're throwing from our user loader callback:

81 lines | src/Security/LoginFormAuthenticator.php
// ... 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
}

All of these exception classes have one important thing in common. Hold Command or Ctrl to open up UserNotFoundException to see it. All of these authentication exceptions have a special getMessageKey() method that contains a safe explanation of why authentication failed. We can use this to tell the user what went wrong.

hide_user_not_found: Showing Invalid Username/Email Errors

So here's the big picture: when authentication fails, it's because something threw an AuthenticationException or one of its sub-classes. And so, since we're throwing a UserNotFoundException when an unknown email is entered... if we try to log in with a bad email, that exception should be passed to onAuthenticationFailure().

Let's test that theory. At the login form, enter some invented email... and... submit. Oh! We still get a BadCredentialsException! I was expecting this to be the actual exception that was thrown: the UserNotFoundException.

For the most part... that is how this works. If you throw an AuthenticationException during the authenticator process, that exception is passed to you down in onAuthenticationFailure(). Then you can use it to figure out what went wrong. However, UserNotFoundException is a special case. On some sites, when the user enters a valid email address but a wrong password, you might not want to tell the user that email was in fact found. So you say "Invalid credentials" both if the email wasn't found or if the password was incorrect.

This problem is called user enumeration: it's where someone can test emails on your login form to figure out which people have accounts and which don't. For some sites, you definitely do not want to expose that information.

And so, to be safe, Symfony converts UserNotFoundException to a BadCredentialsException so that entering an invalid email or invalid password both give the same error message. However, if you do want to be able to say "Invalid email" - which is much more helpful to your users - you can do this.

Open up config/packages/security.yaml. And, anywhere under the root security key, add a hide_user_not_found option set to false:

37 lines | config/packages/security.yaml
security:
// ... lines 2 - 4
hide_user_not_found: false
// ... lines 6 - 37

This tells Symfony to not convert UserNotFoundException to a BadCredentialsException.

If we refresh now... boom! Our UserNotFoundException is now being passed directly to onAuthenticationFailure().

Storing the Authentication Error in the Session

Ok, so let's think. Down in onAuthenticationFailure()... what do we want to do? Our job in this method is, as you can see, to return a Response object. For a login form, what we probably want to do is redirect the user back to the login page but show an error.

To be able to do that, let's stash this exception - which holds the error message - into the session. Say $request->getSession()->set(). We can really use whatever key we want... but there's a standard key that's used to store authentication errors. You can read it from a constant: Security - the one from the Symfony Security component - ::AUTHENTICATION_ERROR. Pass $exception to the second argument:

Tip

In Symfony 6.2 and higher, use the SecurityRequestAttributes class instead: Symfony\Component\Security\Http\SecurityRequestAttributes, then SecurityRequestAttributes::AUTHENTICATION_ERROR.

86 lines | src/Security/LoginFormAuthenticator.php
// ... 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
}

Now that the error is in the session, let's redirect back to the login page. I'll cheat and copy the RedirectResponse from earlier... and change the route to app_login:

86 lines | src/Security/LoginFormAuthenticator.php
// ... 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: Rendering the Error

Cool! Next, inside login() controller, we need to read that error and render it. The most straightforward way to do that would be to grab the session and read out this key. But... it's even easier than that! Symfony provides a service that will grab the key from the session automatically. Add a new argument type-hinted with AuthenticationUtils:

22 lines | src/Controller/SecurityController.php
// ... 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
}
}

And then give render() a second argument. Let's pass an error variable to Twig set to $authenticationUtils->getLastAuthenticationError():

22 lines | src/Controller/SecurityController.php
// ... 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(),
]);
}
}

That's just a shortcut to read that key off of the session.

This means that the error variable is literally going to be an AuthenticationException object. And remember, to figure out what went wrong, all AuthenticationException objects have a getMessageKey() method that returns an explanation.

In templates/security/login.html.twig, let's render that. Right after the h1, say if error, then add a div with alert alert-danger. Inside render error.messageKey:

35 lines | templates/security/login.html.twig
// ... 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 %}

You don't want to use error.message because if you had some sort of internal error - like a database connection error - that message might contain sensitive details. But error.messageKey is guaranteed to be safe.

Testing time! Refresh! Yes! We're redirected back to /login and we see:

Username could not be found.

That's the message if the User object can't be loaded: the error that comes form UserNotFoundException. It's... not a great message... since our users are logging in with an email, not a username.

So next, let's learn how to customize these error messages and add a way to log out.