Chapters
-
Course Code
Subscribe to download the code!Compatible PHP versions: ^7.1.3, <8.0
Subscribe to download the code!Compatible PHP versions: ^7.1.3, <8.0
-
This Video
Subscribe to download the video!
Subscribe to download the video!
-
Subtitles
Subscribe to download the subtitles!
Subscribe to download the subtitles!
-
Course Script
Subscribe to download the script!
Subscribe to download the script!
Authentication Errors
Scroll down to the script below, click on any sentence (including terminal blocks) to jump to that spot in the video!
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
.
Show Lines
|
// ... lines 1 - 41 |
axios | |
Show Lines
|
// ... lines 43 - 52 |
}).catch(error => { | |
if (error.response.data.error) { | |
this.error = error.response.data.error; | |
} else { | |
this.error = 'Unknown error'; | |
} | |
}).finally(() => { | |
Show Lines
|
// ... 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.
Show Lines
|
// ... lines 1 - 41 |
axios | |
Show Lines
|
// ... lines 43 - 45 |
}, { | |
headers: { | |
'content-type': 'application/x-www-form-urlencoded' | |
} | |
}) | |
Show Lines
|
// ... 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.
Show Lines
|
// ... lines 1 - 8 |
class SecurityController extends AbstractController | |
{ | |
Show Lines
|
// ... 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); | |
} | |
Show Lines
|
// ... 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.
23 Comments
Hey @Aaron Kincer!
Hmmm. So, on a "high level", the problem is simple-ish ;).
When you call $iriConverter->getIriFromItem($someObject)
, API Platform looks at whatever class $someObject
to find its @ApiResource
configuration.
In your case, $this->getUser()
is returning an instance of Symfony\Component\Ldap\Security\LdapUser
. And so, API Platform looks at that class and tries to find the @ApiResource
annotation, which of course it doesn't have (since it's a core class).
What's the solution? It depends on what the expected behavior is that you need. Do you have some different, custom User class that DOES have @ApiResource
annotations on it? If so, is the user able to log in via some other method to get that user class (like you have 2 ways to authenticate: LDAP, but also some login form that reads from a User entity in the database)?
Let me know!
Cheers!
Ahh, I see now. LDAP auth is successful, but the user class that's being set is the LdapUser and not the User class. So the question is how do I intercept the login process and use the User class instead of the LdapUser class. I'll dig around and see what I can find.
Hey @Aaron Kincer!
You got it! If you're using the built-in LDAP support in Symfony... at least in the current version, I'm not sure there's an easy way to "hook in" and replace with your own User... which is unfortunate, because it seems really reasonable. The idea I have is this:
1) Register a custom service for this class - https://github.com/symfony/symfony/blob/5.x/src/Symfony/Component/Ldap/Security/LdapUserProvider.php#L32 - this is normally something you configure indirectly as your user provider - https://symfony.com/doc/current/security/ldap.html#fetching-users-using-the-ldap-user-provider - but in this case, register it as a normal service in services.yaml and do NOT put it in security.yaml under your providers.
2) Create your own custom user provider class & service - https://symfony.com/doc/current/security/user_provider.html#creating-a-custom-user-provider - and inject (normal dependency injection via the constructor) the LdapUserProvider from step (1) into your class. Register THIS new service as your user provider in security.yaml.
For this new user provider service, make it implement both UserProviderInterface, PasswordUpgraderInterface and add all those methods to your class. For all of them, just call the inner LdapUserProvider (this is called "decoration" in object oriented land). So for example, you'll have something like this:
class MyCustomUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private $ldapUserProvider;
public function __construct(LdapUserProvider $ldapUserProvider)
{
$this->ldapUserProvider = $ldapUserProvider;
}
public function loadUserByUsername(string $username)
{
return $this->ldapUserProvider-> loadUserByUsername($username);
}
// ... and all the other methods
// make supports() actually support your custom User class, instead of calling the parent method
}
If you do this all correctly... your system will work EXACTLY like it does already :). But now you can hook into the process and and change things. For example, in loadUserByUsername(), you could do something like this:
public function loadUserByUsername(string $username)
{
/** @var LdapUser $ldapUser */
$ldapUser = $this->ldapUserProvider-> loadUserByUsername($username);
// you would inject your UserRepository. And this assumes your entity has an ldapUsername property
$entityUser = $this->userRepository->findOneBy(['ldapUsername' => $ldapUser->getUsername()]);
if (!$entityUser) {
// this is a user that has never logged into our system before, but they ARE valid. So, create
// a new User() object (your custom entity class), add any data on it from the $ldapUser and persist()/flush() to the database
}
return $entityUser;
}
You'll probably need to do some extra work for the other methods as well, like refreshUser(), which is called at the beginning of each request after being logged in.
Two things about this:
1) This is unnecessarily hard :/. That's a bummer! Let me know if you have any problems.
2) Because loadUserByUsername() is called before the password is checked in Ldap, this means that if someone tries to log in with a valid Ldap user but an invalid password, this will still create a row in your local user table. I actually... don't see a huge problem with that. The user would still not be able to log in.. as their Ldap password will be wrong (as long as you don't also have some other way for users in your database to log in).
Cheers!
Think I figured it out. Forgot I copy and pasted a user class from a DIFFERENT project that didn't have the ApiResource tag and forgot to add it back. Thanks!
As always, PEBKAC.
This is simply taking the code from the course and trying to modify it for LDAP as the back-end auth. Of course I do actually want to store some information about the user locally and update from LDAP as necessary. So one obvious thing I see is to intercept what getUser() is returning and simply pull what I want out of an LdapUser object and put it on an actual User class directly there or in a listener. Maybe there's an easier way.
Is there anything change for json_login in symfony 6? I tried with this tutorial and with official Documentation for symfony 6.
https://symfony.com/doc/current/security.html#json-login
Has anyone a working example with symfony 6?
The failure is that the user never logged in.
Following things, I did try:
- With default method
php bin/console make:auth
-> this work fine - Symfony instance tried with
symfony serve
and with simple xampp - changed cookie_secure because I read that this helps some people
- Many different things in security.yaml e.g. with username_path:email and without
- Different content types for axios and postman request e.g. application/x-www-form-urlencoded or application/json
- ...
Maybe the reason is that I use Postman and a Vue instance created with npm run serve
on port 8080
I have no any further idea
Here is access to my Repository
https://gitlab.com/grabasch/api-no-login
I believe I found a workaround. Now use the php bin/console make:auth
method again, and I edit following authenticate method to:
// App\Security\AppCustomAuthenticator.php
public function authenticate(Request $request): Passport
{
$email = $request->request->get('email', '');
$request->getSession()->set(Security::LAST_USERNAME, $email);
// this return will work
return new Passport(
new UserBadge($email),
new PasswordCredentials($request->request->get('password', ''))
);
}
Yesterday as I tried this workaround I did only comment the CsrfTokenBadge and not the complete argument of array
// App\Security\AppCustomAuthenticator.php
public function authenticate(Request $request): Passport
{
$email = $request->request->get('email', '');
$request->getSession()->set(Security::LAST_USERNAME, $email);
// this return does NOT work
return new Passport(
new UserBadge($email),
new PasswordCredentials($request->request->get('password', '')),
[
// new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
]
);
}
Json_login does not still work, but the workaround is fine for me. Suggestions for improvement gladly accepted. :)
Hey Daniel-G!
Thanks for posting your work-around :). Custom authenticators are always an ok solution. It's a bit more work in the start, but it's also super simple to understand how your code is working.
About json_login
, nothing of significance changed that I'm aware of. The tricky thing, as you noticed, is that when it doesn't work, it's not super obvious why! If you care enough - or for anyone else in the future - to debug this, I would put debugging code into this class: https://github.com/symfony/symfony/blob/6.2/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php - that's the code behind json_login
. Specifically, I would add debugging code to determine (A) that supports()
IS being called, (B) that supports()
is returning true
when it should and (C) that authenticate()
is doing its job and returning the Passport
. You could also add debugging code to onAuthenticationSuccess()
and onAuthenticationFailure()
to make sure those are being called when you expect.
Cheers!
Ohhhh nooo, I saw my very stupid mistake. Thanks for your tip to debug my error with JsonLoginAuthenticator Class. :D
I simply used the wrong format. With tool postman, I send a JSON file format in body, but I send as application/text. But there was no error message.
I have a suggestion to help other stupid guys like me in the future. ;-) What can I do? The code is ready in my opinion. But I don't have any experience to suggestion the code in GitHub. I will try now. But maybe you have a tip for me.
UPDATE: I found your video
Organization & Triaging
I think that's should be a good start :)
Hey Daniel!
Woo! Nice job finding the issue! And I saw your PR on GitHub - I'm going to check it more deeply right now. It MAY not be accepted, due to backwards-compatibility concerns, but I think you did a really great job :).
Cheers!
Salut! May I ask a question please? I would be grateful if you can help me!
I am using Symfony for my backend and Angular for the fronend part.
For the authentification, I am using CAS Bundle to login to sso system and everything is going okay! but I can't use the API correctly and I don't know exactly the requested steps for this process.
For now i am using API Platform and NelmioCors bundle. Should I use LexikJWTAuthenticationBundle too? Is there any bundle or steps I should use too?
Hey Lubna!
Hmmm. So you use CAS to log into the SSO system. Do you use Symfony's security system to fully "log in the user" into your Symfony backend (i.e. using normal Symfony security) when you do this? And, is your frontend on the same domain as your backend API?
The easiest way to do this is for your frontend and backend to be on the same domain. If that's true, then you can "login via CAS" and complete the normal Symfony security process so that you are logged in via Symfony. Then... that's it :). Logging into Symfony should cause a session cookie to be set... and then because your frontend and backend are on the same domain, when you make Ajax requests from Angular, they will send the session cookie and the API requests will automatically be authenticated. Very simply.
Let me know if this helps... or if your setup is more complicated.
Cheers!
Hey, everyone.
Is it possible to return an API Platform error response in JSON-LD format in these cases where the route is not managed by API Platform?
Thank you.
Hey André P.
What you could do is to hook into the kernel.exception event of Symfony and look for a 404 error, if it's a 404, then you can return a JsonResponse with the appropriate data.
I leave you the docs reference for more information about events: https://symfony.com/doc/cur...
Cheers!
Hey MolloKhan
What I actually meant was if it was possible to somehow grab the API Platform error responses that already exist instead of creating our own.
Does that make any sense?
What you suggest (I actually already tried it) is to create our own "mimicking" the API Platform one, is that right?
Thank you!
Hey André P. sorry for my late reply, I got my first vaccine dose and it knocked me down for a couple of days. What you mean is to create a custom Error response but make it inherit from inside of ApiPlatform?
there 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?
Hey Gabrielius!
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!
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<br />[2/4]BadRequestHttpException<br />
Symfony\Component\HttpKernel\Exception\BadRequestHttpException:
The key "email" must be provided.
at /var/www/symfony/vendor/symfony/security-http/Firewall/UsernamePasswordJsonAuthenticationListener.php:105<br />[3/4]Error Exception<br />
ErrorException:
Notice: Undefined index: debug
at /var/www/symfony/vendor/symfony/serializer/Normalizer/ProblemNormalizer.php:44<br />[4/4]ErrorException<br />
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?
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/symfony/blob/ae00ff4cfaa377870289cd3e4adf271d1a747049/src/Symfony/Component/Serializer/Normalizer/ProblemNormalizer.php#L44
Cheers!
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']```?
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/symfony/pull/34903
Cheers!
"Houston: no signs of life"
Start the conversation!
What PHP libraries does this tutorial use?
// composer.json
{
"require": {
"php": "^7.1.3, <8.0",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^2.1", // v2.4.5
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^1.6", // 1.11.2
"doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
"doctrine/orm": "^2.4.5", // v2.7.2
"nelmio/cors-bundle": "^1.5", // 1.5.6
"nesbot/carbon": "^2.17", // 2.21.3
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
"symfony/asset": "4.3.*", // v4.3.2
"symfony/console": "4.3.*", // v4.3.2
"symfony/dotenv": "4.3.*", // v4.3.2
"symfony/expression-language": "4.3.*", // v4.3.2
"symfony/flex": "^1.1", // v1.21.6
"symfony/framework-bundle": "4.3.*", // v4.3.2
"symfony/http-client": "4.3.*", // v4.3.3
"symfony/monolog-bundle": "^3.4", // v3.4.0
"symfony/security-bundle": "4.3.*", // v4.3.2
"symfony/twig-bundle": "4.3.*", // v4.3.2
"symfony/validator": "4.3.*", // v4.3.2
"symfony/webpack-encore-bundle": "^1.6", // v1.6.2
"symfony/yaml": "4.3.*" // v4.3.2
},
"require-dev": {
"hautelook/alice-bundle": "^2.5", // 2.7.3
"symfony/browser-kit": "4.3.*", // v4.3.3
"symfony/css-selector": "4.3.*", // v4.3.3
"symfony/maker-bundle": "^1.11", // v1.12.0
"symfony/phpunit-bridge": "^4.3", // v4.3.3
"symfony/stopwatch": "4.3.*", // v4.3.2
"symfony/web-profiler-bundle": "4.3.*" // v4.3.2
}
}
I realize this is a bit unrelated to this tutorial, but I'm trying to adapt this login to using LDAP as the back-end and I think I have everything working but I'm getting this error:
No resource class found for object of type "Symfony\Component\Ldap\Security\LdapUser".
This is the code causing the error:
'Location' => $iriConverter->getIriFromItem($this->getUser())
I'm guessing that I'll have to have the User class extend LdapUser or something?