Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

¿Utilizar o no la autenticación por token de la API?

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

Ésta es la pregunta del millón cuando se trata de la seguridad y las API: ¿necesita mi sitio algún tipo de autentificación por token de API? Es muy probable que la respuesta sea no. Incluso si tu aplicación tiene algunas rúbricas de API -como la nuestra-, si estás creando estas rúbricas únicamente para que tu propio JavaScript para tu propio sitio pueda utilizarlas, entonces no necesitas un sistema de autenticación por token de API. No, tu vida será mucho más sencilla si utilizas un formulario de acceso normal y una autenticación basada en la sesión.

La autenticación basada en la sesión es precisamente la razón por la que tenemos acceso a este punto final: nos hemos conectado previamente... y nuestra cookie de sesión se utiliza para autenticarnos. Esto funciona igual de bien en una página real que en un punto final de la API.

Para probarlo, antes de empezar el tutorial, he creado un controlador Stimulus llamado 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);
}
}

Es muy sencillo: hace una petición a la API... y registra el resultado. Vamos a utilizarlo para hacer una petición de API a /api/me para demostrar que las llamadas Ajax pueden acceder a las rutas autenticadas.

Para activar el controlador Stimulus, abre templates/base.html.twig... y encuentra el elemento body: ese es un lugar fácil para adjuntarlo: siis_granted('IS_AUTHENTICATED_REMEMBERED'), entonces {{ stimulus_controller() }}y el nombre: 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>

Así, nuestro JavaScript será llamado sólo si estamos conectados. Para pasar la URL a la ruta, añade un segundo argumento con url establecido en 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>

Y me doy cuenta de que aún no he dado a nuestro punto final de la API un nombre de ruta... así que vamos a hacerlo:

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

De nuevo en base.html.twig, ¡sí! Mi editor parece feliz ahora.

Vale, vuelvo a la página de inicio, inspecciono el elemento, voy a la consola y... ¡ahí están mis datos de usuario! La petición Ajax envía la cookie de sesión y así... la autenticación funciona.

Así que si lo único que necesita utilizar tu API es tu propio JavaScript, ahórrate un montón de problemas y utiliza simplemente un formulario de acceso. Y si quieres ponerte elegante y enviar tu inicio de sesión por medio de Ajax, puedes hacerlo perfectamente. De hecho, si usas Turbo, eso ocurre automáticamente. Pero si quieres escribir algún JavaScript personalizado, no hay problema. Sólo tienes que utilizar Ajax para enviar el formulario de inicio de sesión y la cookie de sesión se establecerá automáticamente de forma normal. Si decides hacer esto, el único ajuste que necesitarás es hacer que el autentificador del formulario de inicio de sesión devuelva JSON en lugar de redirigir. Yo probablemente volvería a utilizar mi LoginFormAuthenticator personalizado porque sería súper fácil devolver JSON desdeonAuthenticationSuccess():

... 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
}

Cuando sí necesitas tokens de la API

Entonces, ¿cuándo necesitamos un sistema de autenticación por token de API? La respuesta es bastante sencilla: si alguien que no sea el JavaScript de tu propio sitio necesita acceder a tu API... incluso si tu JavaScript vive en un dominio completamente diferente. Si te encuentras en esta situación, probablemente vas a necesitar algún tipo de sistema de tokens de API. Si necesitas OAuth o un sistema más sencillo... depende. No cubriremos los tokens de la API en este tutorial, pero creamos un sistema bastante bueno en nuestro tutorial Symfony 4 Security, que puedes consultar.

Siguiente: ¡vamos a añadir un formulario de registro!

Leave a comment!

21
Login or Register to join the conversation
akincer Avatar
akincer Avatar akincer | posted hace 1 año

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 hace 1 año

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 hace 1 año | 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 hace 1 año | 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!

¡Este tutorial también funciona muy bien para 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