Access Token Authenticator
To authenticate with a token, an API client will send an Authorization header set to the word Bearer then the token string... which is just a standard practice:
$client->request('GET', '/api/treasures', [
'headers' => [
'Authorization' => 'Bearer TOKEN',
],
]);
Then something in our app will read that header, make sure the token is valid, authenticate the user and throw a big party to celebrate.
Activating access_token
Fortunately, Symfony has the perfect system just for this! Spin over and open up config/packages/security.yaml. Anywhere under your firewall add access_token:
| security: | |
| // ... lines 2 - 11 | |
| firewalls: | |
| // ... lines 13 - 15 | |
| main: | |
| // ... lines 17 - 24 | |
| access_token: | |
| // ... lines 26 - 52 |
This activates a listener that will watch every request to see if it has an Authorization header. If it does, it will read that and try to authenticate the user.
Though, it requires a helper class... because even though it knows where to find the token on the request... it has no idea what to do with! It doesn't know if it's a JWT that it should decode... or, in our case, that it can query the database for the matching record. So, to help it, add a token_handler option set to the id of a service we'll create: App\Security\ApiTokenHandler:
| security: | |
| // ... lines 2 - 11 | |
| firewalls: | |
| // ... lines 13 - 15 | |
| main: | |
| // ... lines 17 - 24 | |
| access_token: | |
| token_handler: App\Security\ApiTokenHandler | |
| // ... lines 27 - 52 |
Stateless Firewall
By the way, if your security system only allows authentication via an API token, then you don't need session storage. In that case, you can set a stateless: true flag that tells the security system that when a user authenticates, not to bother storing the user info in the session. I'm going to remove that, because we do have a way to log in that relies on the session.
The Token Handler Class
Ok, let's go create that handler class. In the src/ directory create a new sub-directory called Security/ and inside of that a new PHP class called ApiTokenHandler. This is a beautifully simple class. Make it implement AccessTokenHandlerInterface and then go to "Code"->"Generate" or Command+N on a Mac and select "Implement Methods" to generate the one we need: getUserBadgeFrom():
| // ... lines 1 - 2 | |
| namespace App\Security; | |
| use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; | |
| use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; | |
| class ApiTokenHandler implements AccessTokenHandlerInterface | |
| { | |
| public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge | |
| { | |
| // TODO: Implement getUserBadgeFrom() method. | |
| } | |
| } |
The access_token system knows how to find the token: it knows it will live on an Authorization header with the word Bearer in front of it. So it grabs that string then calls getUserBadgeFrom() and passes it to us. By the way this #[\SensitiveParameter] attribute is new feature in PHP. It's cool, but not important: it just makes sure that if an exception is thrown, this value won't be shown in the stacktrace.
Our job here is to query the database using the $accessToken and then return which user it relates to. To do that, we need the ApiTokenRepository! Add a construct method with a private ApiTokenRepository $apiTokenRepository argument:
| // ... lines 1 - 4 | |
| use App\Repository\ApiTokenRepository; | |
| // ... lines 6 - 9 | |
| class ApiTokenHandler implements AccessTokenHandlerInterface | |
| { | |
| public function __construct(private ApiTokenRepository $apiTokenRepository) | |
| { | |
| } | |
| // ... lines 15 - 25 | |
| } |
Below, say $token = $this->apiTokenRepository and then call findOneBy() passing it an array, so it will query where the token field equals $accessToken:
| // ... lines 1 - 9 | |
| class ApiTokenHandler implements AccessTokenHandlerInterface | |
| { | |
| // ... lines 12 - 15 | |
| public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge | |
| { | |
| $token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]); | |
| // ... lines 19 - 24 | |
| } | |
| } |
If authentication should fail for any reason, we need to throw a type of security exception. For example, if the token doesn't exist, throw a new BadCredentialsException: the one from Symfony components:
| // ... lines 1 - 5 | |
| use Symfony\Component\Security\Core\Exception\BadCredentialsException; | |
| // ... lines 7 - 9 | |
| class ApiTokenHandler implements AccessTokenHandlerInterface | |
| { | |
| // ... lines 12 - 15 | |
| public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge | |
| { | |
| $token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]); | |
| if (!$token) { | |
| throw new BadCredentialsException(); | |
| } | |
| // ... lines 23 - 24 | |
| } | |
| } |
That will cause authentication to fail... but we don't need to pass a message. This will return a "Bad Credentials." message to the user.
At this point, we have found the ApiToken entity. But, ultimately our security system wants to authenticate a user... not an "API Token". We do that by returning a UserBadge that, sort of, wraps the User object. Watch: return a new UserBadge(). The first argument is the "user identifier". Pass $token->getOwnedBy() to get the User and then ->getUserIdentifier():
| // ... lines 1 - 7 | |
| use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; | |
| class ApiTokenHandler implements AccessTokenHandlerInterface | |
| { | |
| // ... lines 12 - 15 | |
| public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge | |
| { | |
| // ... lines 18 - 23 | |
| return new UserBadge($token->getOwnedBy()->getUserIdentifier()); | |
| } | |
| } |
How the User Object is Loaded
Notice that we're not actually returning the User object. That's mostly because... we don't need to! Let me explain. Hold Command or Ctrl and click getUserIdentifier(). What this really returns is the user's email. So we're returning a UserBadge with the user's email inside. What happens next is the same thing that happens when we send an email to the json_login authentication endpoint. Symfony's security system takes that email and, because we have this user provider, it knows to query the database for a User with that email.
So it's going to query the database again for the User via the email... which is a bit unnecessary, but fine. If you want to avoid that, you could pass a callable to the second argument and return $token->getOwnedBy(). But this will work fine as it is.
Oh, and it's probably a good idea to check and make sure the token is valid! If not $token->isValid(), then we could throw another BadCredentialsException. But if you want to customize the message, you can also throw a new CustomUserMessageAuthenticationException with "Token expired" to return that message to the user:
| // ... lines 1 - 6 | |
| use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; | |
| // ... lines 8 - 10 | |
| class ApiTokenHandler implements AccessTokenHandlerInterface | |
| { | |
| // ... lines 13 - 16 | |
| public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge | |
| { | |
| // ... lines 19 - 24 | |
| if (!$token->isValid()) { | |
| throw new CustomUserMessageAuthenticationException('Token expired'); | |
| } | |
| return new UserBadge($token->getOwnedBy()->getUserIdentifier()); | |
| } | |
| } |
Using the Token in Swagger?
And... done! So... how do we try this? Well, ideally we could try it in our Swagger docs. I'm going to open a new tab... then log out. But I'll keep my original tab open... so I can steal these valid tokens!
Head to the API docs. How can we tell this interface to send an API token when it makes the requests? Well you may have noticed an "Authorize" button. But when we click it... it's empty! That's because we haven't, yet, told Open API how users are able to authenticate. Fortunately we can do this via API Platform.
Open up config/packages/api_platform.yaml. And a new key called swagger, though we're actually configuring the OpenAPI docs. To add a new way of authenticating, set api_keys to activate that type, then access_token... which can be anything you want. Below this, give this authentication mechanism a name... and type: header because we want to pass the token as a header:
| api_platform: | |
| // ... lines 2 - 7 | |
| swagger: | |
| api_keys: | |
| access_token: | |
| name: Authorization | |
| type: header | |
| // ... lines 13 - 18 |
This will tell Swagger - via our OpenAPI docs - that we can send API tokens via the Authorization header. Now when we click the "Authorize" button... yea! It says "Name: Authorization", "In Header".
To use this, we need to start with the word Bearer then a space... because it doesn't fill that in for us. More on that in a minute. Let's first try an invalid token. Hit "Authorize". That didn't actually make any requests yet: it just stored the token in JavaScript.
Let's try the get treasure collection endpoint. When we execute... awesome! A 401! We don't need to be authenticated to use this endpoint, but because we passed an Authorization header with Bearer and then a token, the new access_token system caught that, passed the string to our handler... but then we couldn't find a matching token in the database, so we threw the BadCredentialsException
You can see it down here: the API returned an empty response, but with a header containing invalid_token and error_description: "Invalid credentials.".
Checking the Token Authentication is Working
So the bad case is working. Let's try the happy case! In the other tab, copy one of the valid tokens. Then slide back up, hit "Authorize", then "Log out". Logging out just means that it "forgets" the API token we set a minute ago. Re-type Bearer , paste, hit "Authorize", close... and let's go down and try this endpoint again. And... woohoo! A 200!
So it seems like that worked... but how can we tell? Whelp, down on the web debug toolbar, click to open the profiler for that request. On the Security tab... yes! We're logged in as Bernie. Success!
The only thing I don't like is needing to type that Bearer string in the authorization box. That's not super user-friendly. So next, let's fix that by learning how we can customize the OpenAPI spec document that Swagger uses.
19 Comments
Hello machines! After finishing the course I'm trying to add the authenticator to coexist with another one I already had in the project where I'm trying to implement the api. But it seems that something doesn't work, well
This is the security.yaml file:
If I don't put ROLE requirement in api resource, it makes the query and returns fine, but when I put for example that I need ROLE_ADMIN, or any other role, the response is a code 200 and returns the login template that my project has, in this case the LoginAuthenticator.
How can I make these two logins coexist?
That the LoginAuthenticator that my project already had should use sessions and the API one should not use them with
stateless: trueand use the ApiTokenHandler that was made in the course.As far as I'm following the course right now it works fine except for one detail.
When I put in the authentication ‘Bearer asasassa’ the invalid key error appears perfectly, and if I put a correct Bearer it makes the request correctly, but if I don't put any Bearer it also returns the data.
But if I don't set any Bearer it returns the data too, shouldn't it say that there is no api key or that it is not correct?
Hey @Rodrypaladin!
That's actually a great question! When you send with no Bearer, to Symfony, this simply looks like a request that is not trying to send authentication info. The token isn't wrong, there just is no token. The result is that the request is basically "anonymous" / not logged in. If the endpoint that's reached does not require auth, the endpoint will return the real data. If the endpoint does require auth, then it should reject the request with a 401 Unauthorized.
Let me know if that clarifies.
Cheers!
I see this error:
and it works if we do
stateless: true,so in Symfony 7, is this how it works ?
When I authorize using token ( I'm using Symfony 7 ), then it throws following error, but can't we just run session app and API at the same time in Symfony like you're doing in this lecture ?
Hey @sujal_k!
Sorry for the slow reply! To make sure I understand: you need to run a normal session-based security app and a stateless, API token version at the same time? Can you explain the requirements a bit more, that'll help me with a better answer.
Cheers! And sorry to not offer any useful info yet!
When I log out and try to use the token to log in in the authorization button. It doesn't log me in, when I go to security in the profiler, it says "There is no security token.". Even if the token is not valid, I still get a 200 with the data.
I think I followed the steps correctly, any ideas?
Heyy!!
I have the same problem,
i did the same config, and i'm alawys getting a 200 response (with valid or not token plus without Authorization header param)
But when i comment the lazy config i still get a 200 if not Authorization passed in header, and 401 if no valid token
Hi, I experience the same behavior. It seems something is missing in the video...
Can anyone help, please?
@MolloKhan ?
Hey @Twigsee
Sorry for my late reply. I have the same question as here https://symfonycasts.com/screencast/api-platform-security/access-token-authenticator#comment-31679
I believe your authenticator might be misconfigured, let's debug that first
Cheers!
Hi @MolloKhan
I am using Caddy, and having the same issue with loggin in,
When I try to authenticate/ login,
I get a "There is no security token." in my profiler >> security
Does "SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1" this work for Caddy users as well,
I dont know where to put this as I dont have .htaccess file
Hey @Teddy-B
I've not worked with Caddy yet so I don't know how to make it work. Have you asked ChatGPT or any other AI? You may be surprised of how good they are for this kind of problem
Cheers!
Thanks I have heard of ChatGPT never thozght it was something I really wanted to use, but I was indeed impressed with the ansers, I havent solved the issue yet, but I think I am close, I guess I just ask around at stackoverflow then.
Thanks for you quick reply Though!
I figured out I just had to add Content-Type: application -json on the headers and got it working... cant believe it
Ha! So it was not the web server. I thought ApiPlatform adds that header automatically for you but I think Symfony short-circuits the workflow when you try to log in
Thanks for sharing!
I think becaue I am using CORS, intergrating 2 appliciations. I got docker setup and the frontend (React) is seperated from the Backend (Symsony)
Hey @ToNy
Can you see in the profiler the security process kicking in? I think your authenticator is not been called for some reason.
I had my apache misconfigured. I solved with the (in)famous:
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1Thanks for your reply.
Hi !
This example shows how to get User when JWT TOKENs are stored in database.
I'm using LexikJWTAuthenticationBundle. Users are not stored in database.
I want to retrieve Connected User when making request with the generated TOKEN.
How can i do this with class ApiTokenHandler ?
Please help me ! Thanks You
Hi @Ange-B,
Can you give more context? Where exactly do you want to get User info?
IIRC if you are using
LexikJWTAuthenticationBundleit already stores the user info in symfony security system, so it can be easily accessed from anywhere, all you need isloveautowireSecurityclass from Security bundle and use$this->security->getUser()method to get current authenticated userPS maybe I'm missing something so waiting for your feedback
Cheers!
"Houston: no signs of life"
Start the conversation!