Authentication Errors

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

We just found out that, if we send a bad email & password to the built-in json_login authenticator, it sends back a nicely-formed JSON response: an error key set to what went wrong.

Great! We can totally work with that! But, if you do need more control, you can put a key under json_login called failure_handler. Create a class, make it implement AuthenticationFailureHandlerInterface, then use that class name here. With that, you'll have full control to return whatever response you want on authentication failure.

But this is good! Let's use this to show the error on the frontend. If you're familiar with Vue.js, I have a data key called error which, up here on the login form, I use to display an error message. In other words, all we need to do is set this.error to a message and we're in business!

Let's do that! First, if error.response.data, then this.error = error.response.data.error. Ah, actually, I messed up here: I should be checking for error.response.data.error - I should be checking to make sure the response data has that key. And, I should be printing just that key. I'll catch half of my mistake in a minute.

Anyways, if we don't see an error key, something weird happened: set the error to Unknown error.

... lines 1 - 41
axios
... lines 43 - 52
}).catch(error => {
if (error.response.data.error) {
this.error = error.response.data.error;
} else {
this.error = 'Unknown error';
}
}).finally(() => {
... lines 60 - 69

Move over, refresh... and let's fail login again. Doh! It's printing the entire JSON message. Now I'll add the missing .error key. But I should also include it on the if statement above.

Try it again... when we fail login... that's perfect!

json_login Require a JSON Content-Type

But there's one other way we could fail login that we're not handling. Axios is smart... or at least, it's modern. We pass it these two fields - email and password - and it turned that into a JSON string. You can see this in our network tab... down here... Axios set the Content-Type header to application/json and turned the body into JSON.

Most AJAX clients don't do this. Instead, they send the data in a different format that matches what happens when you submit a traditional HTML form. If our AJAX client had done that, what do you think the json_login authenticator would have done? An error?

Let's find out! Temporarily, I'm going to add a third argument to .post(). This is an options array and we can use a headers key to set the Content-Type header to application/x-www-form-urlencoded. That's the Content-Type header your browser sends when you submit a form. This will tell Axios not to send JSON: it will send the data in a format that's invalid for the json_login authenticator.

... lines 1 - 41
axios
... lines 43 - 45
}, {
headers: {
'content-type': 'application/x-www-form-urlencoded'
}
})
... lines 51 - 73

Go refresh the Javascript... and fill out the form again. I'm expecting that we'll get some sort of error. Submit and... huh. A 200 status code? And the response says user: null.

This is coming from our SecurityController! Instead of intercepting the request and then throwing an error when it saw the malformed data... json_login did nothing! It turns out, the json_login authenticator only does its work if the Content-Type header contains the word json. If you make a request without that, json_login does nothing and we end up here in SecurityController.... which is probably not what we want. We probably want to return a response that tells the user what they messed up.

Returning an Error on an Invalid Authentication Request

Simple enough! Inside of the login() controller, we now know that there are two situations when we'll get here: either we hit json_login and were successfully authenticated - we'll see that soon - or we sent an invalid request.

Cool: if !$this->isGranted('IS_AUTHENTICATED_FULLY') - so if they're not logged in - return $this->json() and follow the same error format that json_login uses: an error key set to:

Invalid login request: check that the Content-Type header is "application/json".

And set this to a 400 status code.

... lines 1 - 8
class SecurityController extends AbstractController
{
... lines 11 - 13
public function login()
{
if (!$this->isGranted('IS_AUTHENTICATED_FULLY')) {
return $this->json([
'error' => 'Invalid login request: check that the Content-Type header is "application/json".'
], 400);
}
... lines 21 - 24
}
}

I love it! Let's make sure it works. We didn't change any JavaScript, so no refresh needed. Submit and... we got it! The "error" side of our login is bulletproof.

Head back to our JavaScript and... I guess we should remove that extra header so things work again. Now... we're back to "Invalid credentials".

Next... I think we should try putting in some valid credentials! We'll hack a user into our database to do this and talk about our session-based authentication.

Leave a comment!

  • 2020-02-05 Symfony Student

    Aaah, that makes sense, thank you!

  • 2020-02-04 weaverryan

    Hey Symfony Student!

    Yea, this is tricky... and confusing - understanding this requires understanding how the whole authentication system works. Here is the flow:

    A) We create a json_login system - https://symfonycasts.com/sc... - that will try to authenticate the user whenever the URL is /login. Basically, whenever you go to /login, you will hit this json_login "authentication mechanism" *before* hitting your controller.

    B) If authentication fails inside json_login, IT is responsible for sending the user an error. In this case, the controller will never be called.

    C) If your code DOES get to the controller, it means that authentication is successful. Well... *almost*. There is one case (at least) where authentication was *not* successful, but instead of returning an error, json_login just "allows the request to continue like normal". The most common case is if you send the data in an invalid format. When that happens, the json_login mechanism basically says "Oh, this must not be a request I'm supposed to try to authenticate" and it allows the request to continue, with no error. So, if the code reaches the controller but the user is *not* authenticated, we can assume that the json_login mechanism decided to not even try to authenticate the user. The main (I think only) reason this would happen is an invalid Content-Type header.

    But I agree - when you see the code in the controller, you're like "What the heck?". I hope this helps :).

    Cheers!

  • 2020-02-01 Symfony Student

    weaverryan Hi, at 4:30, we add if (!$this->isGranted('IS_AUTHENTICATED_FULLY')) statement. Is it really a proper way to solve this problem? We try to log in with the wrong request content-type, and then we check if the user is not logged in? How does it work?

  • 2019-12-09 weaverryan

    > I think the code should be:
    >$this->debug && isset($context['debug']) && $context['debug']?

    Ah, I see the problem now!


    $debug = $this->debug && $context['debug'] ?? true;

    The ?? is *exactly* supposed to handle missing keys - it's the null coalescing operating:

    > It returns its first operand if it exists and is not NULL; otherwise, it returns its second operand.

    So, you should NOT get the error. The problem is the "order of operations. This code has a bug - it should be:


    $debug = $this->debug && ($context['debug'] ?? true);

    I'll make a pull request to fix this :) - https://github.com/symfony/...

    Cheers!

  • 2019-12-08 Gabb

    Yes I was passing username on purpose to test what error the api would return. I think the code should be:

    ```$this->debug && isset($context['debug']) && $context['debug']```?

  • 2019-12-08 weaverryan

    Hey Gabb!

    What does your json_login config look like in security.yaml? I think you might have username_path set to email. If you do, it means you need to send an email field in your JSON - not username :).

    About the "debug" thing - not sure about this. I checked the code... and it 100% looks like the code should not be failing if the "debug" array key is missing - so I'm super confused about this one - https://github.com/symfony/...

    Cheers!

  • 2019-12-05 Gabb

    When sending {"username": "test", "password": "test"} to see what happens I get a weird 500 error, it says undefined index: debug

    Actually 4 exceptions:

    [1/4] NoSuchPropertyException
    Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException:
    Neither the property "email" nor one of the methods "getEmail()", "email()", "isEmail()", "hasEmail()", "__get()" exist and have public access in class "stdClass".

    at /var/www/symfony/vendor/symfony/property-access/PropertyAccessor.php:404

    [2/4]BadRequestHttpException
    Symfony\Component\HttpKernel\Exception\BadRequestHttpException:
    The key "email" must be provided.

    at /var/www/symfony/vendor/symfony/security-http/Firewall/UsernamePasswordJsonAuthenticationListener.php:105

    [3/4]Error Exception
    ErrorException:
    Notice: Undefined index: debug

    at /var/www/symfony/vendor/symfony/serializer/Normalizer/ProblemNormalizer.php:44

    [4/4]ErrorException
    ErrorException:
    Notice: Undefined index: debug

    at /var/www/symfony/vendor/symfony/serializer/Normalizer/ProblemNormalizer.php:44

    Yes last two are the same but it is what I see, is it possible to return a 400 Bad Request instead?