LexikJWTAuthenticationBundle icon indicating copy to clipboard operation
LexikJWTAuthenticationBundle copied to clipboard

InsufficientAuthenticationException handling

Open abeal-hottomali opened this issue 3 years ago • 3 comments

This one seems somewhat related to https://github.com/lexik/LexikJWTAuthenticationBundle/issues/489. I'm writing because I may have discovered a bug in \Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator::start (or I may have just misconfigured something by mistake, hopefully you'll know which).

Background: I'm using this bundle in an APIP project (currently Symfony 5.0.x), and I want to configure a security declaration for a "Location" entity that uses a custom voter. That voter is supposed to return true for voteOnAttribute if the user is anonymous, and the item is active. If the user is anonymous, and the item is inactive, it should return false. The request is hitting my main firewall, which uses the jwt token authenticator in its work.

        main:
            anonymous: lazy
            stateless: true
            provider: app_user_provider
            user_checker: App\Security\UserChecker
            json_login:
                check_path: /authentication_token
                username_path: email
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

The relevant access control directive is:

    access_decision_manager:
        strategy: affirmative
        allow_if_all_abstain: true
    access_control:
        # Other stuff...
        - { path: ^/locations.*$, roles: IS_AUTHENTICATED_ANONYMOUSLY, methods: [GET] }

Problem:

I'm getting 'JWT Token not found' in my output. This is expected (I'm anonymous), but what I really want here is just a straight-up AccessDeniedException or AccessDeniedHttpException, since the resource isn't supposed to need a token in all circumstances.

The message in question is being triggered by \Symfony\Component\Security\Http\Firewall\ExceptionListener::handleAccessDeniedException, which recognizes that the caller is anonymous, and is throwing an insufficient authentication exception. That in turn triggers \Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator::start, which triggers a MissingTokenException in all circumstances.

Is this an instance of me misusing the 'security' directive, or should this authenticator be checking that the resource allows anonymous user access?

Updates

  • Using version 2.10.6 of this bundle, at the moment, if it makes a difference.

abeal-hottomali avatar May 05 '21 18:05 abeal-hottomali

Just found this related: https://github.com/lexik/LexikJWTAuthenticationBundle/issues/298

abeal-hottomali avatar May 05 '21 21:05 abeal-hottomali

I ended up resolving this by decorating the authenticator (based on some solutions in https://github.com/lexik/LexikJWTAuthenticationBundle/issues/298):

// \App\Security\AppTokenAuthenticator
<?php

declare(strict_types=1);

namespace App\Security;

use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

/**
 * @see JWTTokenAuthenticator
 *
 * Our decorator adds special handling for the anonymous use case.
 * Adopted from https://github.com/lexik/LexikJWTAuthenticationBundle/issues/298#issuecomment-673408586
 */
class AppTokenAuthenticator extends JWTTokenAuthenticator
{
    private ?FirewallMap $firewallMap;
    private ?AuthenticationTrustResolverInterface $authenticationTrustResolver;
    private ?KernelInterface $kernel;
    private JWTTokenAuthenticator $decorated;
    private EventDispatcherInterface $dispatcher;

    public function __construct(
        JWTTokenAuthenticator $decorated,
        JWTTokenManagerInterface $jwtManager,
        EventDispatcherInterface $dispatcher,
        TokenExtractorInterface $tokenExtractor,
        TokenStorageInterface $preAuthenticationTokenStorage
    ) {
        $this->decorated = $decorated;

        parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage);
        $this->dispatcher = $dispatcher;
    }

    public function setFirewallMap(FirewallMap $firewallMap): void
    {
        $this->firewallMap = $firewallMap;
    }

    public function setTrustResolver(AuthenticationTrustResolverInterface $trustResolver): void
    {
        $this->authenticationTrustResolver = $trustResolver;
    }

    public function setKernel(KernelInterface $kernel)
    {
        $this->kernel = $kernel;
    }

    public function start(Request $request, AuthenticationException $authException = null)
    {
        if (null === $authException) {
            return parent::start($request, $authException);
        }
        // Only takes effect for anonymous access violations.
        if ($this->authenticationTrustResolver->isFullFledged($authException->getToken())) {
            return parent::start($request, $authException);
        }
        // If the firewall does not allow anonymous, default behaviour applies.
        if (!$this->firewallMap->getFirewallConfig($request)->allowsAnonymous()) {
            return parent::start($request, $authException);
        }
        // We need to return a normal 403 access denied response.
        $subrequest = $request->duplicate(null, null, [
            'exception' => $authException,
        ]);
        $subrequest->setMethod(Request::METHOD_GET);

        return $this->kernel->handle($subrequest, HttpKernelInterface::SUB_REQUEST, false);
    }
}

# api/config/services.yaml
services:
    App\Security\AppTokenAuthenticator:
        decorates: lexik_jwt_authentication.security.guard.jwt_token_authenticator
        calls:
            - [ 'setTrustResolver', [ '@security.authentication.trust_resolver' ] ]
            - [ 'setFirewallMap', [ '@security.firewall.map' ] ]
            - [ 'setKernel', [ '@Symfony\Component\HttpKernel\KernelInterface' ] ]

This gives me what I was looking for - a properly serialized "Access Denied" error in places where endpoints allow anonymous users, but voters reject access.

abeal-hottomali avatar May 05 '21 22:05 abeal-hottomali

Here is my workaround for the most recent version of the bundle:

<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\JWTAuthenticator;
use Symfony\Component\Security\Http\AccessMapInterface;

final class AppTokenAuthenticator extends JWTAuthenticator
{
    private ?AccessMapInterface $accessMap = null;

    public function setAccessMap(AccessMapInterface $accessMap): void
    {
        $this->accessMap = $accessMap;
    }

    /**
     * {@inheritdoc}
     */
    public function supports(Request $request): ?bool
    {
        // Add your logic here to check if the current path has public access

        // If the path has public access, return false to skip token validation
        if ($this->isPublicPath($request)) {
            return false;
        }

        return parent::supports($request);
    }

    private function isPublicPath(Request $request): bool
    {
        [$roles, $channel] = $this->accessMap->getPatterns($request);

        if ($roles[0] === 'PUBLIC_ACCESS' || $roles[0] === 'IS_AUTHENTICATED_ANONYMOUSLY') {
            return true;
        }

        return false;
    }

}

Then I declare the service in services.yaml:

services:
    app.jwt_authenticator:
        class: App\Security\AppTokenAuthenticator
        parent: lexik_jwt_authentication.security.guard.jwt_token_authenticator
        calls:
            - [ 'setAccessMap', [ '@security.access_map' ] ]

Then I set the authenticator in security.yaml:

security:
    firewalls:
        main:
            jwt:
                authenticator: app.jwt_authenticator

calls9-tylersmith avatar Jul 16 '23 14:07 calls9-tylersmith