When Authentication Fails
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeGo 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()
:
// ... 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:
// ... 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:
// ... 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
:
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
.
// ... 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
:
// ... 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
:
// ... 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()
:
// ... 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
:
// ... 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.
Hi,
I am trying it using Symfony 7.0 and now it's using
SecurityRequestAttributes::AUTHENTICATION_ERROR
instead of current approach. May be, it will be helpful for others.Thanks