Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

To use API Token Authentication or Not?

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Here's the million-dollar question when it comes to security and APIs: does my site need some sort of API token authentication? There's a pretty good chance that the answer is no. Even if your app has some API endpoints - like ours - if you're creating these endpoints solely so that your own JavaScript for your own site can use them, then you do not need an API token authentication system. Nope, your life will be much simpler if you use a normal login form and session-based authentication.

Session-based authentication is precisely why we have access to this endpoint: we previously logged in... and our session cookie is used to authenticate us. This works just as well on a real page as on an API endpoint.

To prove it, before I started the tutorial, I created a Stimulus controller called user-api_controller.js:

import { Controller } from 'stimulus';
import axios from 'axios';
export default class extends Controller {
static values = {
url: String
}
async connect() {
const response = await axios.get(this.urlValue);
console.log(response.data);
}
}

It's dead simple: it makes an API request... and logs the result. We're going to use it to make an API request to /api/me to prove that Ajax calls can access the authenticated endpoints.

To activate the Stimulus controller, open templates/base.html.twig... and find the body element: that's an easy place to attach it: if is_granted('IS_AUTHENTICATED_REMEMBERED'), then {{ stimulus_controller() }} and the name: user-api:

... line 1
<html>
... lines 3 - 14
<body
{% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
{{ stimulus_controller('user-api', {
... line 18
}) }}
{% endif %}
>
... lines 22 - 85
</body>
</html>

So, our JavaScript will be called only if we're logged in. To pass the URL to the endpoint, add a 2nd arg with url set to path('app_user_api_me'):

... line 1
<html>
... lines 3 - 14
<body
{% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
{{ stimulus_controller('user-api', {
url: path('app_user_api_me')
}) }}
{% endif %}
>
... lines 22 - 85
</body>
</html>

And I'm realizing that I haven't given our API endpoint a route name yet... so let's do that:

... lines 1 - 7
class UserController extends BaseController
{
/**
* @Route("/api/me", name="app_user_api_me")
... line 12
*/
public function apiMe()
{
... lines 16 - 18
}
}

Back in base.html.twig, yup! My editor looks happy now.

Ok, head back to the homepage, inspect element, go to the console and... there's my user data! The Ajax request sends the session cookie and so... authentication just works.

So if the only thing that needs to use your API is your own JavaScript, save yourself a lot of trouble and just use a login form. And if you do want to get fancy and submit your login for via Ajax, you can totally do that. In fact, if you use Turbo, that happens automatically. But if you wanted to write some custom JavaScript, it's still no problem. Just use Ajax to submit the login form and the session cookie will be automatically set like normal. If you do decide to do this, the only tweak you'll need is to make your login form authenticator return JSON instead of redirecting. I would probably go back to using my custom LoginFormAuthenticator because it would be super easy to return JSON from onAuthenticationSuccess():

... lines 1 - 26
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 29 - 66
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
... lines 69 - 75
}
... lines 77 - 81
}

When You Do Need API Tokens

So then, when do we need an API token authentication system? The answer is pretty simple: if someone other than your own site's JavaScript needs to access your API... including if your JavaScript lives on a completely different domain. If you have this situation, you're probably going to need some sort of API token system. Whether you need OAuth or a simpler system... depends. We won't cover API tokens in this tutorial, but we create a pretty nice system in our Symfony 4 Security tutorial, which you can check out.

Next: let's add a registration form!

Leave a comment!

21
Login or Register to join the conversation
akincer Avatar
akincer Avatar akincer | posted 1 year ago

Aw man. I had been waiting to see token auth in the new system only to see it's not covered because I have a use case that definitely requires them. The Lexik JWT bundles look like they are pretty well documented and I'll probably use them anyway rather than reinvent the wheel so I'll check there.

2 Reply

Hey @Aaron!

Yea, sorry about that - this tutorial was getting long. I'm still thinking about possibly doing it in a little security extra tutorial... but I'm not sure yet :). Btw, what's your use-case for needing tokens (just out of curiosity)?

Cheers!

1 Reply
akincer Avatar

Automated clients interacting via API Platform for which a shared login is not desired. Don't really want to have a username/password stored on devices that would need to be updated. Clients would be manually ingested so tokens would be approved manually. I guess individual login requests tied to the same username/password COULD work but my gut tells me that could be an ugly hack. Definitely wouldn't want individual username/passwords per client. Open to suggestions I haven't considered since I'm fairly new in general to Symfony and have tackled the problem I'm trying to solve exactly zero times previously.

Reply

Hey @Aaron!

> Don't really want to have a username/password stored on devices that would need to be updated

👍

Honestly, it sounds like you need exactly what we created in our Symfony 4 tutorial. It's dead simple and works perfectly when you have a situation where API tokens are created how you're describing: https://symfonycasts.com/sc...

You'll have to translate the "Guard authenticator" in that tutorial into a new-style authenticator, but that should be doable if I did a a decent job of teaching the authenticator in this tutorial ;). The one new thing is that, with API tokens (you'll see this in the tutorial), there really isn't any credentials or password to check. In this situation, instead of returning a Passport object from authenticate() in the new system, return a SelfValidatingPassport (it's the same as Passport, but you don't need to pass any 2nd argument "credentials").

If you hit any snags, lemme know!

Cheers!

Reply
akincer Avatar

Refactoring so far going pretty well. I spent longer than I care to admit troubleshooting why I was getting a familiar error of "Full authentication is required to access this resource." One thing you didn't explicitly say but in retrospect was heavily implied and upon consideration makes total sense is that the actual order of the firewalls matters.

One thing I'm fast approaching needing to sort out is exactly what should be returned to the API client in various authentication scenarios from a standards perspective. Do you have any good reading recommendations on this topic that explains it well?

Reply

Hey @Aaron!

> One thing you didn't explicitly say but in retrospect was heavily implied and upon consideration makes total sense is that the actual order of the firewalls matters.

Ah yes, good catch! I spend so little time on multiple firewalls that this detail slips through.

> One thing I'm fast approaching needing to sort out is exactly what should be returned to the API client in various authentication scenarios from a standards perspective. Do you have any good reading recommendations on this topic that explains it well?

I hate to say "it depends"... because it's a cop out. But it does depend on how you're doing authentication. But it also doesn't really matter - do your best to stick to standards, but at the end of the day, it's not that important: just get something working. So by "authentication scenarios", I assume you mean that the client is making some sort of request to authenticate themselves (e.g. send email/password in exchange for a token). Is that accurate? If so, it's common to have a POST /api/login where you send that stuff as Json, then return the token in JSON as a response. It's a bit of a "one-off" endpoint, but works well. You could also create a POST /api/tokens endpoint where you POST the email/password and this "creates a token resource" and returns it. Basically the same thing as before, but it looks more RESTful. But let me know what your situation is - I thought you mentioned earlier that API keys would be approved by admins and wouldn't be created in the way that I'm describing above, but I can't remember for sure.

Cheers!

Reply
akincer Avatar

Thanks I'll take a look. Seems like I took that course so it will be a refresher.

Reply
DanielG Avatar

Hello Ryan! I have a small problem and I do not understand if it is something I did wrong or maybe it's a bug. I will try to describe the situation:

I am upgrading my project from symfony 5 to 6 and I am also changing the authentication system to use the new authenticator_manager. I have an API which has the normal usrname/password jwt token authentication but I also have a second authentication for a token coming from my api gateway (I just need to verify the signature on that one and then pass the request along). If I try to get the user from the passport (in the createToken() method in my own custom authenticator) then I get this exception (Cannot register a bag when the session is already started.). If I get the user using the Security dependency injection it works fine. I am getting the session after a successful authentication to do some operations on the user but I do not understand what happens with the session in my use case.

Thank you so much for the tutorial and your time!

Reply

Hey @DanielG!

Hmm, this is very odd! In createToken(), fetching the user from the Passport is the intended way for things to work - you can see it in the abstract class - https://github.com/symfony/symfony/blob/6.2/src/Symfony/Component/Security/Http/Authenticator/AbstractAuthenticator.php

So then, why this strange error? My guess is that you have something, somewhere, which is a bit custom, and which is causing this - but I'm not sure what. Some things to look at:

A) How is your data provider configured? The ->getUser() method on the Passport will execute your data provider to load the user the first time it is called.

B) You mentioned that you are getting the session after a successful authentication to do some operations. What does this look like?

C) Is it possible that both of your authenticators are trying to operate on the same request - e.g. when you POST the username/password, both authenticators are trying to handle this?

I'd also be interested in the stack trace for the error... I can't think, immediately, how calling ->getUser() would trigger something so deep in the session.

Cheers!

Reply
DanielG Avatar

I found the issue. It was just like you said, someone before my time used the session by injecting SessionInterface in an event listener instead of getting it from RequestStack and apparently that was causing the conflict.
Thanks for pointing me in the right direction.

Reply

Hey Daniel!

Sorry for the slow reply - holidays! Good find! I'm glad you saw this - the stackt trace you posted looked fine... since the error was triggered by the "correct" usage of the session... but it was caused by that earlier, hidden code. Anyways, good job!

Cheers!

Reply
DanielG Avatar

I also discovered that if I set stateless: false for this firewall everything works as intended.

Reply
DanielG Avatar

This is the stack trace:

#0 /var/www/app/vendor/symfony/http-foundation/Session/Session.php(250): Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage->registerBag(Object(Symfony\Component\HttpFoundation\Session\SessionBagProxy))
#1 /var/www/app/vendor/symfony/http-foundation/Session/Session.php(50): Symfony\Component\HttpFoundation\Session\Session->registerBag(Object(Symfony\Component\HttpFoundation\Session\Attribute\AttributeBag))
#2 /var/www/app/vendor/symfony/http-foundation/Session/SessionFactory.php(38): Symfony\Component\HttpFoundation\Session\Session->__construct(Object(Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage), Object(Symfony\Component\HttpFoundation\Session\Attribute\AttributeBag), NULL, Object(Closure))
#3 /var/www/app/vendor/symfony/http-kernel/EventListener/SessionListener.php(28): Symfony\Component\HttpFoundation\Session\SessionFactory->createSession()
#4 /var/www/app/vendor/symfony/http-kernel/EventListener/AbstractSessionListener.php(72): Symfony\Component\HttpKernel\EventListener\SessionListener->getSession()
#5 /var/www/app/vendor/symfony/http-foundation/Request.php(698): Symfony\Component\HttpKernel\EventListener\AbstractSessionListener->Symfony\Component\HttpKernel\EventListener\{closure}()
#6 /var/www/app/vendor/symfony/http-foundation/RequestStack.php(102): Symfony\Component\HttpFoundation\Request->getSession()
#7 /var/www/app/vendor/sylius/sylius/src/Sylius/Bundle/CoreBundle/Provider/SessionProvider.php(19): Symfony\Component\HttpFoundation\RequestStack->getSession()
#8 /var/www/app/vendor/sylius/sylius/src/Sylius/Bundle/CoreBundle/Storage/CartSessionStorage.php(38): Sylius\Bundle\CoreBundle\Provider\SessionProvider::getSession(Object(Symfony\Component\HttpFoundation\RequestStack))
#9 /var/www/app/vendor/sylius/sylius/src/Sylius/Bundle/CoreBundle/Context/SessionAndChannelBasedCartContext.php(37): Sylius\Bundle\CoreBundle\Storage\CartSessionStorage->hasForChannel(Object(App\Entity\Channel))
#10 /var/www/app/src/Context/CompositeCartContext.php(82): Sylius\Bundle\CoreBundle\Context\SessionAndChannelBasedCartContext->getCart()
#11 /var/www/app/src/ShareCart/CartBlamerListener.php(88): App\Context\CompositeCartContext->getCart()
#12 /var/www/app/src/ShareCart/CartBlamerListener.php(49): App\ShareCart\CartBlamerListener->getCart()
#13 /var/www/app/src/ShareCart/CartBlamerListener.php(106): App\ShareCart\CartBlamerListener->blame(Object(Sylius\Component\Core\Model\ShopUser))
#14 /var/www/app/vendor/symfony/event-dispatcher/Debug/WrappedListener.php(111): App\ShareCart\CartBlamerListener->onInteractiveLogin(Object(Symfony\Component\EventDispatcher\GenericEvent), 'app.custom_logi...', Object(Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher))
#15 /var/www/app/vendor/symfony/event-dispatcher/EventDispatcher.php(230): Symfony\Component\EventDispatcher\Debug\WrappedListener->__invoke(Object(Symfony\Component\EventDispatcher\GenericEvent), 'app.custom_logi...', Object(Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher))
#16 /var/www/app/vendor/symfony/event-dispatcher/EventDispatcher.php(59): Symfony\Component\EventDispatcher\EventDispatcher->callListeners(Array, 'app.custom_logi...', Object(Symfony\Component\EventDispatcher\GenericEvent))
#17 /var/www/app/vendor/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php(152): Symfony\Component\EventDispatcher\EventDispatcher->dispatch(Object(Symfony\Component\EventDispatcher\GenericEvent), 'app.custom_logi...')
#18 /var/www/app/src/EventSubscriber/AuthenticationSubscriber.php(68): Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher->dispatch(Object(Symfony\Component\EventDispatcher\GenericEvent), 'app.custom_logi...')
#19 /var/www/app/vendor/symfony/event-dispatcher/EventDispatcher.php(270): App\EventSubscriber\AuthenticationSubscriber->onAuthenticationSuccess(Object(Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent), 'security.authen...', Object(Symfony\Component\EventDispatcher\EventDispatcher))
#20 /var/www/app/vendor/symfony/event-dispatcher/EventDispatcher.php(230): Symfony\Component\EventDispatcher\EventDispatcher::Symfony\Component\EventDispatcher\{closure}(Object(Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent), 'security.authen...', Object(Symfony\Component\EventDispatcher\EventDispatcher))
#21 /var/www/app/vendor/symfony/event-dispatcher/EventDispatcher.php(59): Symfony\Component\EventDispatcher\EventDispatcher->callListeners(Array, 'security.authen...', Object(Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent))
#22 /var/www/app/vendor/symfony/security-http/Authentication/AuthenticatorManager.php(210): Symfony\Component\EventDispatcher\EventDispatcher->dispatch(Object(Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent), 'security.authen...')
#23 /var/www/app/vendor/symfony/security-http/Authentication/AuthenticatorManager.php(160): Symfony\Component\Security\Http\Authentication\AuthenticatorManager->executeAuthenticator(Object(Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator), Object(Symfony\Component\HttpFoundation\Request))
#24 /var/www/app/vendor/symfony/security-http/Authentication/AuthenticatorManager.php(140): Symfony\Component\Security\Http\Authentication\AuthenticatorManager->executeAuthenticators(Array, Object(Symfony\Component\HttpFoundation\Request))
#25 /var/www/app/vendor/symfony/security-http/Firewall/AuthenticatorManagerListener.php(40): Symfony\Component\Security\Http\Authentication\AuthenticatorManager->authenticateRequest(Object(Symfony\Component\HttpFoundation\Request))
#26 /var/www/app/vendor/symfony/security-http/Authenticator/Debug/TraceableAuthenticatorManagerListener.php(65): Symfony\Component\Security\Http\Firewall\AuthenticatorManagerListener->authenticate(Object(Symfony\Component\HttpKernel\Event\RequestEvent))
#27 /var/www/app/vendor/symfony/security-bundle/Debug/WrappedLazyListener.php(49): Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticatorManagerListener->authenticate(Object(Symfony\Component\HttpKernel\Event\RequestEvent))
#28 /var/www/app/vendor/symfony/security-http/Firewall/AbstractListener.php(26): Symfony\Bundle\SecurityBundle\Debug\WrappedLazyListener->authenticate(Object(Symfony\Component\HttpKernel\Event\RequestEvent))
#29 /var/www/app/vendor/symfony/security-bundle/Debug/TraceableFirewallListener.php(73): Symfony\Component\Security\Http\Firewall\AbstractListener->__invoke(Object(Symfony\Component\HttpKernel\Event\RequestEvent))
#30 /var/www/app/vendor/symfony/security-http/Firewall.php(92): Symfony\Bundle\SecurityBundle\Debug\TraceableFirewallListener->callListeners(Object(Symfony\Component\HttpKernel\Event\RequestEvent), Object(Generator))
#31 /var/www/app/vendor/symfony/event-dispatcher/Debug/WrappedListener.php(111): Symfony\Component\Security\Http\Firewall->onKernelRequest(Object(Symfony\Component\HttpKernel\Event\RequestEvent), 'kernel.request', Object(Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher))
#32 /var/www/app/vendor/symfony/event-dispatcher/EventDispatcher.php(230): Symfony\Component\EventDispatcher\Debug\WrappedListener->__invoke(Object(Symfony\Component\HttpKernel\Event\RequestEvent), 'kernel.request', Object(Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher))
#33 /var/www/app/vendor/symfony/event-dispatcher/EventDispatcher.php(59): Symfony\Component\EventDispatcher\EventDispatcher->callListeners(Array, 'kernel.request', Object(Symfony\Component\HttpKernel\Event\RequestEvent))
#34 /var/www/app/vendor/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php(152): Symfony\Component\EventDispatcher\EventDispatcher->dispatch(Object(Symfony\Component\HttpKernel\Event\RequestEvent), 'kernel.request')
#35 /var/www/app/vendor/symfony/http-kernel/HttpKernel.php(139): Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher->dispatch(Object(Symfony\Component\HttpKernel\Event\RequestEvent), 'kernel.request')
#36 /var/www/app/vendor/symfony/http-kernel/HttpKernel.php(75): Symfony\Component\HttpKernel\HttpKernel->handleRaw(Object(Symfony\Component\HttpFoundation\Request), 1)
#37 /var/www/app/vendor/symfony/http-kernel/Kernel.php(202): Symfony\Component\HttpKernel\HttpKernel->handle(Object(Symfony\Component\HttpFoundation\Request), 1, true)
#38 /var/www/app/public/index.php(40): Symfony\Component\HttpKernel\Kernel->handle(Object(Symfony\Component\HttpFoundation\Request))
#39 {main}

And here is the user provider:

class UsernameOrEmailProvider extends AbstractUserProvider
{
    protected function findUser(string $uniqueIdentifier): ?UserInterface
    {
        if (filter_var($uniqueIdentifier, \FILTER_VALIDATE_EMAIL)) {
            return $this->userRepository->findOneByEmail($uniqueIdentifier);
        }

        return $this->userRepository->findOneBy(['usernameCanonical' => $uniqueIdentifier]);
    }
}

I still haven't found the cause and the workaround I used initially doesn't work if I also register the user in the authenticator (eg. for a social login action)

Reply
Lechu85 Avatar
Lechu85 Avatar Lechu85 | posted 1 year ago

it is a pity that there is no authorization described here using API token.
The tutorial for symfony 4 is depreciated. There is no description of how to create a new Passport, new UserBadge, etc. :(

I think some short mention would be useful here :)

Reply

Hey Leszek C.!

Ah, I might still do it... in a mini-tutorial :). This tutorial was just getting SO long, but I think it's probably a good idea to do it. No promises, but I think I'm going to add an extra mini-tutorial for this stuff.

Btw, if you have any questions that I could answer now, lemme know!

Cheers!

2 Reply
Lechu85 Avatar
Lechu85 Avatar Lechu85 | weaverryan | posted 1 year ago | edited

It would be great if there was a chapter about authentication for Api token in symfony 5/6. I have been looking for a solution to this problem for two weeks. I am unable to generate a passport correctly. Only this stop me to revrite my legacy application to symfony.

I'm using the entities from your the symfony 4 tutorial :)
That is my code:

class ApiTokenAuthenticator extends AbstractAuthenticator
{

    private ApiTokenRepository $apiTokenRepository;

    public function __construct(ApiTokenRepository $apiTokenRepository)
    {
        $this->apiTokenRepository = $apiTokenRepository;
    }

    public function supports(Request $request): ?bool
    {
        return $request->headers->has('Authorization')
            && 0 === strpos($request->headers->get('Authorization'), 'Bearer ');
    }

    public function authenticate(Request $request): PassportInterface
    {
        $authorizationHeader = $request->headers->get('Authorization');
        $apiToken = substr($authorizationHeader, 7);

        if (null === $apiToken) {
            throw new CustomUserMessageAuthenticationException('No API token provided');
        }

        return new Passport(
            new UserBadge($apiToken, function ($userIdentifier) {
                $user = $this->apiTokenRepository->findOneBy([
                    'token' => $userIdentifier
                ]);

                if (!$user) {
                    throw new UserNotFoundException();
                }
                return $user;
            }),
            new CustomCredentials(function ($credentials, User $user) {
                return true; //For exampe, I don't know what give here
            }, $apiToken)
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
        ];
        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

I tried with SelfValidatingPassport but it also didn't work.

<blockquote>Error msg: {"message":"Authentication request could not be processed due to a system problem."} and dump for $exception: "The user provider must return a UserInterface object, "App\Entity\ApiToken" given."</blockquote>

Can you help me?

Reply
Default user avatar
Default user avatar Aaron Kincer | Lechu85 | posted 1 year ago | edited

Here's my final ApiKeyAuthenticator.php with an added option for manually approving tokens which you can obviously remove if that doesn't apply to your situation. Which reminds me Leszek C. I may eventually have questions on the best practices (assuming any) for token requesting/refreshing. I'll experiment a bit.


namespace App\Security;

use App\Repository\ApiTokenRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;

class ApiKeyAuthenticator extends AbstractAuthenticator
{
    private $apiTokenRepository;
    private $userRepository;
    private $logger;
    private $entityManager;
    private $router;

    public function __construct(ApiTokenRepository $apiTokenRepository, UserRepository $userRepository, LoggerInterface $logger, EntityManagerInterface $entityManager, RouterInterface $router)
    {

        $this->apiTokenRepository = $apiTokenRepository;
        $this->userRepository = $userRepository;
        $this->logger = $logger;
        $this->entityManager = $entityManager;
        $this->router = $router;
    }

    public function supports(Request $request): ?bool
    {
        return $request->headers->has('Authorization')
            && 0 === strpos($request->headers->get('Authorization'), 'Bearer ');
    }

    public function authenticate(Request $request): PassportInterface
    {
        $logger = $this->logger;

        /* Get the API Token string by skipping beyond "Bearer */
        $apiToken = substr($request->headers->get('Authorization'), 7);

        return new Passport(
            new UserBadge($apiToken, function($userIdentifier) use ($logger)
            {

                /* Does the account already exist? */
                $token = $this->apiTokenRepository->findOneBy([
                    'token' => $userIdentifier
                ]);

                if (!$token)
                {
                    /* Token doesn't exist */
                    throw new CustomUserMessageAuthenticationException(
                        'Invalid API Token'
                    );
                }
                elseif ($token->isExpired())
                {
                    /* Token expired */
                    throw new CustomUserMessageAuthenticationException(
                        'Expired API Token'
                    );
                }
                elseif (!$token->isApproved())
                {
                    /* Token unapproved */
                    throw new CustomUserMessageAuthenticationException(
                        'Unapproved API Token'
                    );
                }

                return $token->getUser();
            }),
            new CustomCredentials(function() {
                return true;
            }, $apiToken)
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse([
            'message' => $exception->getMessageKey()
        ], 401);
    }
}

2 Reply

Hey @Aaron!

This is VERY awesome of you to share - thank you! And, it makes a ton of sense - you nailed it. One minor simplification you could make it just remove the "elseif". What I mean is, this would work also:


                if (!$token)
                {
                    /* Token doesn't exist */
                    throw new CustomUserMessageAuthenticationException(
                        'Invalid API Token'
                    );
                }
                
                if ($token->isExpired())
                {
                    /* Token expired */
                    throw new CustomUserMessageAuthenticationException(
                        'Expired API Token'
                    );
                }

                if (!$token->isApproved())
                {
                    /* Token unapproved */
                    throw new CustomUserMessageAuthenticationException(
                        'Unapproved API Token'
                    );
                }

                return $token->getUser();

It's minor, it just helps the code "look" a little complex. Because each if statement throws an exception, and so exits the function (that part is very-well-written), you don't need to nest the next part in an else. You actually already did this perfectly at the end of the function by having return $token->getUser(); by itself instead of inside an "else".

Anyways, thank you for sharing this - it's super awesome!

1 Reply
akincer Avatar

Thanks! I had the statements individual if statements. It's an old habit to combine related if/then statements into a single code block from CompSci days in college. I want to say a professor long ago said this was optimal at compile time. Maybe I'm making that up. Anyway it's not a compiled language so who cares.

I've further cleaned up the code a bit since the EM isn't needed. But it's all minor tweaks at this point. If one wanted to get even more crazy with it they could order the conditions based on likelihood of prevalence for minor performance improvement under load but it's not going to make or break anything.

Reply
akincer Avatar

I don't have this fully working yet but I see your immediate issue -- you're trying to get the user from the token repository. You have to get the user from the token using getUser().

Reply

Yea, to follow up on what Aaron is saying, you're returning an ApiToken object as your "User". That, in theory is possible and a valid thing to do... but it depends on what you're trying to do. If you're trying to "query for the matching API Token and then authenticate as whatever User that API Token belongs to", then you need to query for the ApiToken (like you already are), but then probably return something like $apiToken->getUser() (assuming that you have a relationship between ApiToken and User).

But it's also possible that you're intending for the "thing that is authenticated" to be the ApiToken itself - i.e. "you are authenticated as this token" vs "you are authenticated as this user". If that's the case, your ApiToken entity needs to implement UserInterface.

Other than that, things are looking really good!

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}
userVoice