Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

InsufficientAuthenticationException handling #862

Open
abeal-hottomali opened this issue May 5, 2021 · 3 comments
Open

InsufficientAuthenticationException handling #862

abeal-hottomali opened this issue May 5, 2021 · 3 comments

Comments

@abeal-hottomali
Copy link

abeal-hottomali commented May 5, 2021

This one seems somewhat related to #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
Copy link
Author

Just found this related: #298

@abeal-hottomali
Copy link
Author

abeal-hottomali commented May 5, 2021

I ended up resolving this by decorating the authenticator (based on some solutions in #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.

@calls9-tylersmith
Copy link

calls9-tylersmith commented Jul 16, 2023

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants