LexikJWTAuthenticationBundle
LexikJWTAuthenticationBundle copied to clipboard
InsufficientAuthenticationException handling
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.
Just found this related: https://github.com/lexik/LexikJWTAuthenticationBundle/issues/298
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.
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