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 SubscribeWhat about a test like this... but where we log in with an API key? Let's do that! Create a new method: public function testPostToCreateTreasureWithApiKey()
:
... lines 1 - 10 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 13 - 61 | |
public function testPostToCreateTreasureWithApiKey(): void | |
{ | |
... lines 64 - 70 | |
} | |
} |
This will start pretty much the same as before. I'll copy the top of the previous test, remove the actingAs()
... and add a dump()
near the bottom:
... lines 1 - 10 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 13 - 61 | |
public function testPostToCreateTreasureWithApiKey(): void | |
{ | |
$this->browser() | |
->post('/api/treasures', [ | |
'json' => [], | |
]) | |
->dump() | |
->assertStatus(422) | |
; | |
} | |
} |
So, like before, we're sending invalid data and expect a 422 status code.
Copy that method name, then spin over and run just this test:
symfony php bin/phpunit --filter=testPostToCreateTreasureWithApiKey
And... no surprise: we get a 401 status code because we're not authenticated.
Let's send an Authorization
header, but an invalid one to start. Pass a headers
key set to an array with Authorization
and then word Bearer
and then... foo
.
This should still fail:
symfony php bin/phpunit --filter=testPostToCreateTreasureWithApiKey
And... it does! But with a different error message: invalid_token
. Nice!
To pass a real token, we need to put a real token into the database. Do that with $token = ApiTokenFactory::createOne()
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 63 | |
public function testPostToCreateTreasureWithApiKey(): void | |
{ | |
$token = ApiTokenFactory::createOne([ | |
... line 67 | |
]); | |
... lines 69 - 79 | |
} | |
} |
Do we need to control any fields on this? We actually do. Open up DragonTreasure
. If we scroll up, the Post
operation requires ROLE_TREASURE_CREATE
:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 37 | |
new Post( | |
security: 'is_granted("ROLE_TREASURE_CREATE")', | |
), | |
... lines 41 - 49 | |
], | |
... lines 51 - 64 | |
) | |
... lines 66 - 83 | |
class DragonTreasure | |
{ | |
... lines 86 - 243 | |
} |
When we authenticate via the login form, thanks to role_hierarchy
, we always have that. But when using an API key, to get that role, the token needs the corresponding scope.
To make sure we have it, back in the test, set the scopes
property to ApiToken::SCOPE_TREASURE_CREATE
:
... lines 1 - 4 | |
use App\Entity\ApiToken; | |
... lines 6 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 63 | |
public function testPostToCreateTreasureWithApiKey(): void | |
{ | |
$token = ApiTokenFactory::createOne([ | |
'scopes' => [ApiToken::SCOPE_TREASURE_CREATE] | |
]); | |
... lines 69 - 79 | |
} | |
} |
Now pass this to the header: $token->getToken()
. Oh... and let me fix scopes
: that should be an array:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 63 | |
public function testPostToCreateTreasureWithApiKey(): void | |
{ | |
$token = ApiTokenFactory::createOne([ | |
'scopes' => [ApiToken::SCOPE_TREASURE_CREATE] | |
]); | |
... line 69 | |
$this->browser() | |
->post('/api/treasures', [ | |
... line 72 | |
'headers' => [ | |
'Authorization' => 'Bearer '.$token->getToken() | |
] | |
]) | |
... lines 77 - 78 | |
; | |
} | |
} |
I think we're ready! Run that test:
symfony php bin/phpunit --filter=testPostToCreateTreasureWithApiKey
And... got it! We see the beautiful 422 validation errors!
Let's test to make sure we don't have access if our token is missing this scope. Copy the entire test method... then paste below. Call it testPostToCreateTreasureDeniedWithoutScope()
.
This time, set scopes
to something else, like SCOPE_TREASURE_EDIT
. Below, we now expect a 403 status code:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 80 | |
public function testPostToCreateTreasureDeniedWithoutScope(): void | |
{ | |
$token = ApiTokenFactory::createOne([ | |
'scopes' => [ApiToken::SCOPE_TREASURE_EDIT] | |
]); | |
$this->browser() | |
->post('/api/treasures', [ | |
'json' => [], | |
'headers' => [ | |
'Authorization' => 'Bearer '.$token->getToken() | |
] | |
]) | |
->assertStatus(403) | |
; | |
} | |
} |
This time, let's run all the tests:
symfony php bin/phpunit
And... all green! A 422 then a 403. Go remove the dumps from both those spots.
By the way, if you use API tokens a lot in your tests, passing the Authorization
header can get annoying. Browser has a way where we can create a custom Browser object with custom methods. For example, you could add an authWithToken()
method, pass an array of scopes, and then it would create that token and set it into the header
$this->browser()
->authWithToken([ApiToken::SCOPE_TREASURE_CREATE])
// ...
;
This totally does not work right now, but check out Browser's docs to learn how.
Next: in API Platform 3.1, the behavior of the PUT
operation is changing. Let's talk about how, and what we need to do in our code to prepare for it.
Hey @Chtioui!
Hmm very interesting! So, you're using LexikJWTAuthenticationBundle with "stateless" authentication is that correct? What I mean is: you send the JWT with every API request, right? You do not rely on just "logging in once" and then using the session to authenticate you on future request. Let me know if that assumption is incorrect :).
Anyways, if you ARE using "stateless" authentication, then I have no idea what's happening yet :P.
But if you ARE relying on session authentication, then I still don't totally know what's happening, but I can offer some info. On each request, Symfony attempts to load the User
object from the session. It then checks to see if some of the important fields on that User
object (the one that was stored in the session) have since changed in the database - for example, if the password has been changed. If any of these fields have changed, it does NOT load your User
(i.e. it effectively logs you out). This is a security mechanism so that if you change your password on one computer because someone hacked your account, it will log ALL devices out of your account immediately. The logic for this is here: https://github.com/symfony/symfony/blob/0eb03203c800b11bac4496a3e84c75e2966d5507/src/Symfony/Component/Security/Http/Firewall/ContextListener.php#L281-L321
However, normally, if you change your password on a request, then at the end of that request, when Symfony serializes the User
into the session, it will serialize the User
object that contains the NEW hashed password. And so, in the next request, everything will work fine.
So, something feels weird to me. Can you tell me a bit more about your situation - are you using stateless auth or session-based auth? When exactly do you get the "Invalid credentials" error - is that on the NEXT request? Are you sending a JWT on that? What does your JWT contain?
Cheers!
Hello @weaverryan,
-Yes i'm using stateless authentication with the LexikJWTAuthenticationBundle and i'm using the user's username and password for generating the JWT token.
-I get the "Invalid credentials" error on the next request and the user is no longer authenticated in my web app.
-I think whats happening is normal because the user is updating his login credentials (username & password in my case) that's why i'm getting the error "Invalid credentials" with the status 401 also I get this error only when update the user's login credentials but if I update other properties of the user's class everything works fine and I got no errors from the api response.
-I want to know if there is any mechanism to integrate to solve the problem or do I need to authenticate again the user after he updates one of his login credentials.
*firewalls in security.yaml:
security firewall
firewalls:
api:
pattern: ^/api/
stateless: true
provider: app_user_provider
jwt: ~
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
json_login:
check_path: /authentication_token
username_path: username
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
refresh_jwt:
check_path: /authentication_token/refresh # or, you may use the `api_refresh_token` route name
logout:
path: app_logout
Lexik bundle file configuration:
Lexic bundle configuration file
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 1800 # 30min in seconds 1800
Cheers!
Hey @Chtioui!
Hmm. If the user changes their username, it DOES make sense that you would lose authentication since the username
is what's added to the JWT... and then that username
is read from the JWT to find the user. So if you change the username, that user won't be found. To fix that, you could change your JWT to use the id
instead, which is probably safer anyways. I'm not sure exactly how you're supposed to do this - but the https://symfony.com/bundles/LexikJWTAuthenticationBundle/current/2-data-customization.html seems to be close.
I don't understand why changing the password
is also causing problems - but you did mention that:
Yes i'm using stateless authentication with the LexikJWTAuthenticationBundle and i'm using the user's username and password for generating the JWT token
So perhaps you're adding and using the password in the token in some manual way already.
Anyways, to fix the issue, you'll need to either:
A) Re-authenticate after this (as you know is already possible)
or
B) Somehow send back a fresh JWT from the user endpoint where you update the email/password. I'm not sure how standard this is... but in theory, you could register an event listener that could add a custom response header - e.g. X-JWT
to this endpoint. It's a non-trivial problem. You might, inside User::setPlainPassword()
and setUsername()
set some flag on your User
like $this->needsNewJWT = true
. Then register a ResponseListener
and look for that (you can get the User
object via $request->attributes->get('data')
.
I hope this gives you some hints :)
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}
Hello symfonycasts Team !
i'm using LexikJWTAuthenticationBundle to handle token authentication everything works fine until I tried to update a user's login credentials (password & email) with the PATCH operation the update request succedeed but i've got token this error : "Invalid credentials" after finishing updating the user I want to understand something here do we need to login again the user after updating his login credentials or therre is any other solution to generate a new token after the user updates his login credentials?
Cheers!